/soil

A Web Framework written in Crystal optimized for productivity and extensibility

Primary LanguageCrystalMIT LicenseMIT

Soil

Soil is a Web Framework written in Crystal optimized for productivity and extensibility.

Build Status

It is inspired by the most well known Ruby and Javascript web frameworks, inheriting the completeness of Ruby on Rails, the clarity and readability of ExpressJS and the simplicity of Sinatra.

Current state

Soil is still a work in progress with a lot of missing features, but it will be constantly updated until it reaches a stable version.

Installation

Add Soil to shard.yml:

dependencies:
  soil:
    github: joaodiogocosta/soil
    branch: master

And then run:

$ crystal deps

Basic Example

The following is a basic example that defines a GET /posts endpoint and responds with JSON.

# blog_app.cr

class BlogApp < Soil::App
  get "posts" do |req, res|
    posts = [...]
    res.json(posts)
  end
end

BlogApp.new.run

And then run:

$ crystal blog_app.cr

Table of Contents

Routes

Definition

Defining routes is extremely easy.

First, define a class that acts as a routes container, such as a Controller does in other web frameworks, and then define an arbitrary amount of routes by using methods such as get and passing a block handler that accepts req (request) and res (response) as arguments.

class BlogApp < Soil::App
  get "/" do |req, res|
    # ...
  end

  post "/" do |req, res|
    # ...
  end
end

Handlers

A Route accepts two different types of handlers, crystal's built-in Proc and Action.

Proc

A Proc is the simplest handler. To use it, simply pass in a block that accepts req (request) and res (response) as arguments:

class BlogApp < Soil::App
  get "/" do |req, res|
    # ...
  end
end

Action

An Action is a plain Crystal object that includes Soil::Action, which is a module that ensures a proper call method is implemented:

class PostsIndexAction
  include Soil::Action

  def call(req, res)
    posts = [...]
    res.json(posts)
  end
end

class PostsApp < Soil::App
  get "/", PostsIndexAction.new
end

This is particularly useful for endpoints that incorporate a considerable amount of operations, such as an enpoint that creates a new User account:

class CreateUserAction
  include Soil::Action

  def initialize
    @email_sender = EmailSender.new
  end

  def call(req, res)
    user = create_user(req.params["user"])

    if user
      send_welcome_email(user)
      res.json(user)
    else
      res.status_code = 400
      res.json("message" => "Could not create User")
    end
  end

  private def create_user(attributes)
    User.create(attributes)
  end

  private def send_welcome_email(user)
    @email_sender.send(:welcome_email, user.email)
  end
end

And then use it in a Route:

class BlogApp < Soil::App
  get "/", CreateUserAction.new
end

Multiple Handlers

Routes can have multiple handlers. Proc and Action can be combined in arrays:

class LogAction
  include Soil::Action

  def call(req, res)
    # Log something to STDOUT
  end
end

class PostsIndexAction
  include Soil::Action

  def call(req, res)
    posts = [...]
    res.json(posts)
  end
end

class PostsApp < Soil::App
  get "/", [
    LogAction.new,
    PostsIndexAction.new,
    -> (req : Soil::Http::Request, res : Soil::Http::Response) {
      # ...
    }]
end

Params

URL

URL named parameters are captured and accessible via req.params.url:

class BlogApp < Soil::App
  get "/posts/:post_id/comments/:comment_id" do |req, res|
    req.params.url["post_id"] # => "45"
    req.params.url["comment_id"] # => "87"
  end
end

Query

Query parameters are accessible via req.params.query:

class BlogApp < Soil::App
  get "/posts?search=crystal" do |req, res|
    req.params.query["search"] # => "crystal"
  end
end

JSON

JSON parameters are captured from the body and accessible via req.params.json.

Given the following JSON payload:

{
  "post": {
    "title": "Soil is pure awesomeness!"
  }
}

Here's how you would access its values:

class BlogApp < Soil::App
  post "/posts" do |req, res|
    req.params.json["post"]["title"] # => "Soil is pure awesomeness!"
  end
end

Response

JSON

Soil has built-in support for JSON responses. The following will call to_json on the object passed in as argument and write to the response body.

class Api < Soil::App
  get "posts" do |req, res|
    posts = [...]
    res.json(posts)
  end
end

This method will set the Content-Type header to application/json.

Render Templates

Render any template (HTML or others) by calling render on the response object and passing any View class. Read more about views here.

class MyApp < Soil::App
  get "/" do |req, res|
    res.render(IndexView)
  end
end

This method will set the Content-Type header to text/html.

Inline HTML

For inline HTML:

class HtmlApp < Soil::App
  get "/" do |req, res|
    res.html("<html></html>")
  end
end

This method will set the Content-Type header to text/html.

Text

For simple plain text responses:

class TextApp < Soil::App
  get "/" do |req, res|
    res.text("Soil says hi!")
  end
end

This method will set the Content-Type header to text/plain.

Redirect

Use redirect to redirect the request to another path or url:

class TextApp < Soil::App
  get "/" do |req, res|
    res.redirect("/login")

    # or

    res.redirect("http://google.pt")
  end
end

This method will set the status code to 302 or 303 for GET and POST requests, respectively.

Hooks

Hooks are evaluated before or after each request within the same context of the request. They run before/after every request defined within the App or within mounted Apps.

class Posts < Soil::App

  before do |req, res|
    pp req.params # print params before handler
  end

  after do |req, res|
    pp res.status_code # print status code after handler
  end

  # ...
end

Namespaces

The following wil prepend blog to all routes, hence /blog/posts:

class BlogApp < Soil::App
  namespace "blog"

  get "posts" do |req, res|
    posts = [...]
    res.json(posts)
  end
end

Namespaces are propagated to all routes.

Nested Routes

Nested Routes are the foundation of Soil's extensibility features.

Soil Apps can be combined to create complex applications of multiple endpoints with multiple levels of nesting.

class Users < Soil::App
  get "/" do |req, res|
    users = [...]
    res.json(users)
  end
end

class Comments < Soil::App
  get "/" do |req, res|
    # ...
    req.params["post_id"] => # 231
    # ...
  end
end

class Posts < Soil::App
  mount ":post_id/comments", CommentsApp

  get "/" do |req, res|
    posts = [...]
    res.json(posts)
  end
end

class BlogApp < Soil::App
  namespace "blog"

  mount "/users", UsersApp
  mount "/posts", PostsApp
end

This will generate the following routes:

GET /blog/users

GET /blog/posts

GET /blog/posts/:post_id/comments

Views and Layouts

Views and templating engines are a crucial part of every web framework. Soil's approach is to provide enough flexibility to the developer while enforcing good practices, such as having a reasonably well-defined data structure.

The easy way, ideal for simple templates

Soil has a built-in helper to render templates. It is available in regular routes and also in any Action subclasses.

class MyApp < Soil::App
  get "/" do |req, res|
    render_template res, "index.html.ecr"
  end
end

Then create a new file which is the actual template. Soil uses Crystal's built-in templating engine ECR:

(index.html.ecr)

<p>I Love <%= name %>!</p>

The preferred way, ideal for templates with dynamic data

Declare a new class that will act as the data container for the view template:

class IndexView
  include Soil::View

  def initialize(data)
    @name = data["name"]
  end

  def name
    @name
  end

  def render(io : IO)
    render_template io, "index.html.ecr"
  end
end

It's mandatory to implement a render(io : IO) method. You can return whatever you want, in this case we will return a pre-compiled template.

Views are plain Crystal objects, you are free to implement them as you find more suitable to your needs, just remember to make publicly accessible the methods that are used in the template (name in this case).

Ultimately, reference it in your request handler by calling render just like any text or JSON response:

class MyApp < Soil::App
  get "/" do |req, res|
    res.render(IndexView, { "name" => "Crystal" })
  end
end

The result will be:

<p>I love Crystal!<p>

Layouts

Soil supports View Layouts.

Given a layout template, add the placeholder yield_contents to where you want to render a nested template:

(layout.html.ecr)

<div>
  <%= yield_contents %>
</div>

And then reference this file in the View:

class IndexView
  include Soil::View

  def render(io : IO)
    render_template io, "index.html.ecr", layout: "layout.html.ecr"
  end
end

The contents of index.html.ecr will replace <%= yield_contents %> in the layout.

Static Files

To serve static files just enable the following configuration option:

class MyApp < Soil::App
  configure do |config|
    config.serve_static_files = true
  end
end

This will serve files inside public/.

If you want to change the public directory from which the files are served, use the configuration option public_dir:

config.public_dir = "custom_dir"`

Configuration

As of now, Soil accepts configuration options for the following parameters:

class BlogApp < Soil::App
  configure do |config|
    # Binding
    config.host = "127.0.0.1"
    config.port = "4000"

    # Static Files
    config.serve_static_files = false
    config.public_dir = "public"
  end

  # ...
end

Requirements

Crystal - Please refer to http://crystal-lang.org/docs/installation for instructions for your operating system.

Contributing

Everyone is invited to make Soil a better project:

  1. Fork it ( https://github.com/joaodiogocosta/soil/fork )
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

License

MIT License

Copyright (c) 2017 João Diogo Costa

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.