Skip to content

sammyhenningsson/shaf

Repository files navigation

Shaf (Sinatra Hypermedia API Framework)

Gem Version CI
Shaf is a framework for building hypermedia driven REST APIs. Its goal is to be like a lightweight version of rails new --api with hypermedia as a first class citizen. Instead of reinventing the wheel Shaf uses Sinatra and adds a layer of conventions similar to Rails. It uses Sequel as ORM and HALPresenter for policies and serialization (which means that the main mediatype is HAL).
Most APIs claiming to be RESTful completly lacks the concept of links and relies upon clients to construction urls to known endpoints. Thoses APIs are missing some of the concepts that Roy Fielding put together in is dissertation about REST.
If you don't have full understanding of what REST is then that's fine. Though you are encouraged to read up on the basics. Check out this blog for a great explanation of the building blocks of REST.
A short version is: REST was "invented" by describing how the web is architectured. Web components (e.g browsers, servers, cache proxies etc) all use the same interface, where URIs and mediatypes play a big part. This enables any browser to connect to any web server without prior knowledge about each other. An important part of this is to use hypermedia links, which makes it possible for components to evolve independently.

Building a REST API requires knowledge about standards and a lot of boring stuff. Shaf aims to reduce those prerequirements, minimize bikeshedding and to get you up and running quickly. Some of the benefits of using Shaf is that you get:

  • Scaffolding
  • Serialization
  • Authorization
  • Content negotiation
  • Documentation
  • Forms
  • Uri helpers
  • Pagination
  • Testing
  • HTTP caching
  • Link preloading (enables HTTP2 Push)

What's unique about Shaf?

The list above could be implemented in any web framework. So why use Shaf? Well, if you are comfortable writing it yourself, then perhaps Shaf might not be for you. However it can still be nice to have some conventions to rely upon, instead of having to decide all basic details. Like choosing a mediatype for instance (this is something people have very strong/different opinions about).
I don't think there's another Ruby web framework that emphasizes the principles of REST as much as Shaf does. An example of a unique feature (AFAIK) is that mediatypes are separated from controller actions. In most other frameworks, each controller action specifies the possible content type to be returned. In Shaf, controller actions returns Ruby objects. Depending on what clients want to receive (specified by the Accept header), the appropriate serializer is looked up and used to respond with the right representation. (This is not 100% true, since there's actually a helper, respond_with, that produces the usual [status_code, headers, response]. However you would still see it as an object being returned and serialization takes place afterwards. Parsing inputs is done using a similar approach.
The most unique feature is probably the usage of mediatype profiles, which are used both for machine readable definitions and for generating documentation.(See Mediatype profiles for more information.)

Getting started

Install Shaf with

gem install shaf

Then create a new project with shaf new followed by the name of the project. E.g.

shaf new blog

This will create a new directory with a bunch of files that make up the basics of a new API. Change into the this directory and install any missing depencencies.

cd blog
bundle

Your newly created project should contain the following files:

.
├── api
│   ├── controllers
│   │   ├── base_controller.rb
│   │   ├── docs_controller.rb
│   │   └── root_controller.rb
│   ├── policies
│   │   └── base_policy.rb
│   └── serializers
│       ├── base_serializer.rb
│       ├── documentation_serializer.rb
│       ├── error_serializer.rb
│       ├── form_serializer.rb
│       ├── root_serializer.rb
│       └── validation_error_serializer.rb
├── config
│   ├── bootstrap.rb
│   ├── customize.rb
│   ├── database.rb
│   ├── database.yml
│   ├── directories.rb
│   ├── helpers.rb
│   ├── initializers
│   │   ├── authentication.rb
│   │   ├── db_migrations.rb
│   │   ├── hal_presenter.rb
│   │   ├── logging.rb
│   │   └── sequel.rb
│   ├── initializers.rb
│   ├── paths.rb
│   └── settings.yml
├── config.ru
├── frontend
│   ├── assets
│   │   └── css
│   │       └── main.css
│   └── views
│       ├── form.erb
│       ├── headers.erb
│       ├── layout.erb
│       └── payload.erb
├── Gemfile
├── Gemfile.lock
├── Rakefile
└── spec
    ├── integration
    │   └── root_spec.rb
    ├── serializers
    │   └── root_serializer_spec.rb
    └── spec_helper.rb

You now have a functional API. Start the server with

shaf server

Then in another terminal run

curl localhost:3000/

Which should return the following payload.

{
  "_links": {
    "self": {
      "href": "http://localhost:3000/"
    }
  }
}

Hint: The output will actually not have any newlines and will look a bit more dense. To make the output more readable pipe the curl command to jq (which is a great a tool for dealing with json strings).

curl localhost:3000/ | jq

Or if you don't have jq installed, you can also pretty print json through Ruby. E.g:

curl localhost:3000/ | ruby -rjson -e "puts JSON.pretty_generate(JSON.parse(STDIN.read))"

The project also contains a few specs that you can run with rake

shaf test

Currently your API is pretty useless. Let's fix that by generating some scaffolding. The following command will create a new resource with two attributes (title and message).

shaf generate scaffold post title:string message:string 

This will output:

Added:      api/models/post.rb
Added:      db/migrations/20180224225335_create_posts_table.rb
Added:      api/serializers/post_serializer.rb
Added:      spec/serializers/post_serializer_spec.rb
Added:      api/policies/post_policy.rb
Added:      api/profiles/post.rb
Added:      api/forms/post_forms.rb
Added:      api/controllers/posts_controller.rb
Added:      spec/integration/posts_controller_spec.rb
Modified:   api/serializers/root_serializer.rb

As shown in the output, that command created, a model, a controller, a serializer and a policy. It also generated a DB migration file, some forms, some specs and a link to the new post collection was added the root resource. So let's check this out by migrating the DB and restarting the server. Close any running instance with Ctrl + C and then:

rake db:migrate
shaf server

Again in another terminal run

curl localhost:3000/ | jq

Which should now return the following payload.

{
  "_links": {
    "self": {
      "href": "http://localhost:3000/"
    },
    "posts": {
      "href": "http://localhost:3000/posts"
    }
  }
}

The root payload should now contain a link with rel posts. Lets follow that link..

curl localhost:3000/posts | jq

The response looks like this

{
  "_links": {
    "self": {
      "href": "http://localhost:3000/posts?page=1&per_page=25"
    },
    "up": {
      "href": "http://localhost:3000/"
    },
    "create-form": {
      "href": "http://localhost:3000/post/form"
    },
    "curies": [
      {
        "name": "doc",
        "href": "http://localhost:3000/doc/profiles/post{#rel}",
        "templated": true
      }
    ]
  },
  "_embedded": {
    "posts": []
  }
}

This is the collection of posts (which currently is empty, see $response['_embedded']['posts']). Notice the link with rel create-form. This is the api telling us that we may add new post resources. Let's follow that link!

curl http://localhost:3000/post/form | jq

The response looks like this

{
  "method": "POST",
  "name": "create-post",
  "title": "Create Post",
  "href": "http://localhost:3000/posts",
  "type": "application/json",
  "submit": "save",
  "_links": {
    "profile": {
      "href": "http://localhost:3000/doc/profiles/shaf-form"
    },
    "self": {
      "href": "http://localhost:3000/post/form"
    },
    "curies": [
      {
        "name": "doc",
        "href": "http://localhost:3000/doc/profiles/shaf-form{#rel}",
        "templated": true
      }
    ]
  },
  "fields": [
    {
      "name": "title",
      "type": "string"
    },
    {
      "name": "message",
      "type": "string"
    }
  ]
}

This form shows us how to create new post resources (see Forms for more info). A new post resource can be created with the following request

curl -H "Content-Type: application/json" \
     -d '{"title": "hello", "message": "lorem ipsum"}' \
     localhost:3000/posts | jq

The response shows us the new resource, with the attributes that we set as well as links for updating and deleting it.

{
  "title": "hello",
  "message": "lorem ipsum",
  "_links": {
    "profile": {
      "href": "http://localhost:3000/doc/profiles/post"
    },
    "collection": {
      "href": "http://localhost:3000/posts"
    },
    "self": {
      "href": "http://localhost:3000/posts/1"
    },
    "edit-form": {
      "href": "http://localhost:3000/posts/1/edit"
    },
    "doc:delete": {
      "href": "http://localhost:3000/posts/1"
    },
    "curies": [
      {
        "name": "doc",
        "href": "http://localhost:3000/doc/profiles/post{#rel}",
        "templated": true
      }
    ]
  }
}

This new resource is of course added to the collection of posts, which can now be retrieved by the link with rel collection.

curl localhost:3000/posts | jq

Response:

{
  "_links": {
    "self": {
      "href": "http://localhost:3000/posts?page=1&per_page=25"
    },
    "up": {
      "href": "http://localhost:3000/"
    },
    "create-form": {
      "href": "http://localhost:3000/post/form"
    },
    "curies": [
      {
        "name": "doc",
        "href": "http://localhost:3000/doc/profiles/post{#rel}",
        "templated": true
      }
    ]
  },
  "_embedded": {
    "posts": [
      {
        "title": "hello",
        "message": "lorem ipsum",
        "_links": {
          "profile": {
            "href": "http://localhost:3000/doc/profiles/post"
          },
          "collection": {
            "href": "http://localhost:3000/posts"
          },
          "self": {
            "href": "http://localhost:3000/posts/1"
          },
          "edit-form": {
            "href": "http://localhost:3000/posts/1/edit"
          },
          "doc:delete": {
            "href": "http://localhost:3000/posts/1"
          }
        }
      }
    ]
  }
}

Recap

We have built a very basic hypermedia driven API with only one type of resource. The neatest thing about this is that it only took four commands:

shaf new blog
bundle
shaf generate scaffold post title:string message:string 
rake db:migrate

Documentation

Contributing

If you find a bug or have suggestions for improvements, please create a new issue on Github. Pull request are welcome!

License

The gem is available as open source under the terms of the MIT License.