diff --git a/README.md b/README.md index 6caee9e..32b98ea 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,12 @@ # Shaf (Sinatra Hypermedia API Framework) [![Gem Version](https://badge.fury.io/rb/shaf.svg)](https://badge.fury.io/rb/shaf) ![CI](https://github.com/sammyhenningsson/shaf/workflows/CI/badge.svg) -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](http://sinatrarb.com/) and adds a layer of conventions similar to [Rails](http://rubyonrails.org/). It uses [Sequel](http://sequel.jeremyevans.net/) as ORM and [HALPresenter](https://github.com/sammyhenningsson/hal_presenter) for policies and serialization (which means that the mediatype being used is [HAL](http://stateless.co/hal_specification.html)). +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](http://sinatrarb.com/) and adds a layer of conventions similar to [Rails](http://rubyonrails.org/). It uses [Sequel](http://sequel.jeremyevans.net/) as ORM and [HALPresenter](https://github.com/sammyhenningsson/hal_presenter) for policies and serialization (which means that the main mediatype is [HAL](http://stateless.co/hal_specification.html)). 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. Perhaps [this blog](https://apisyouwonthate.com/blog/rest-and-hypermedia-in-2019) might make things a little bit more clear. -_TL;DR_: REST was "invented" by describing how the web is architectured. 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. -In my oppinion, the goal of REST APIs is to be a part of that web architecture. As an example this means that any hypermedia client, speaking HAL (or some other hypermedia type) should be able to communicate with any API which can return HAL responses without prior knowledge. Of course a developer and/or a user needs to guide the client into what actions to take, but it shouldn't require a special Foo Client to talk to a Foo API. +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](https://apisyouwonthate.com/blog/rest-and-hypermedia-in-2019) 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 up and running quickly. Some of the benefits of using Shaf is that you get: +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 @@ -20,6 +19,14 @@ Building a REST API requires knowledge about standards and a lot of boring stuff - 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](doc/PROFILES.md) for more information.) + ## Getting started Install Shaf with ```sh @@ -46,6 +53,7 @@ Your newly created project should contain the following files: │   │   └── base_policy.rb │   └── serializers │   ├── base_serializer.rb +│   ├── documentation_serializer.rb │   ├── error_serializer.rb │   ├── form_serializer.rb │   ├── root_serializer.rb @@ -58,6 +66,7 @@ Your newly created project should contain the following files: │   ├── directories.rb │   ├── helpers.rb │   ├── initializers +│   │   ├── authentication.rb │   │   ├── db_migrations.rb │   │   ├── hal_presenter.rb │   │   ├── logging.rb @@ -72,9 +81,11 @@ Your newly created project should contain the following files: │   │   └── main.css │   └── views │   ├── form.erb +│   ├── headers.erb │   ├── layout.erb │   └── payload.erb ├── Gemfile +├── Gemfile.lock ├── Rakefile └── spec ├── integration @@ -103,15 +114,18 @@ Which should return the following payload. ``` _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 `ruby -rjson -e "puts (JSON.pretty_generate JSON.parse(STDIN.read))"`. E.g. +curl command to `jq` (which is a great a tool for dealing with json strings). +```sh +curl localhost:3000/ | jq +``` +Or if you don't have `jq` installed, you can also pretty print json through Ruby. E.g: ```sh curl localhost:3000/ | ruby -rjson -e "puts JSON.pretty_generate(JSON.parse(STDIN.read))" ``` -(Or better yet, use `jq` which is a great a tool for dealing with json strings) The project also contains a few specs that you can run with `rake` ```sh -rake test +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`). @@ -125,6 +139,7 @@ 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 @@ -152,7 +167,7 @@ Which should now return the following payload. } } ``` -The root payload now contains a link with rel _posts_. Lets follow that link.. +The root payload should now contain a link with rel _posts_. Lets follow that link.. ```sh curl localhost:3000/posts | jq ``` @@ -172,7 +187,7 @@ The response looks like this "curies": [ { "name": "doc", - "href": "http://localhost:3000/doc/post/rels/{rel}", + "href": "http://localhost:3000/doc/profiles/post{#rel}", "templated": true } ] @@ -194,28 +209,35 @@ The response looks like this "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" }, - "profile": { - "href": "https://gist.githubusercontent.com/sammyhenningsson/39c8aafeaf60192b082762cbf3e08d57/raw/shaf-form.md" - } + "curies": [ + { + "name": "doc", + "href": "http://localhost:3000/doc/profiles/shaf-form{#rel}", + "templated": true + } + ] }, "fields": [ { "name": "title", - "type": "string", + "type": "string" }, { "name": "message", - "type": "string", + "type": "string" } ] } - ``` -This form shows us how to create new post resources (see [Forms](doc/FORMS.md) and [the shaf-form media type profile](https://gist.github.com/sammyhenningsson/39c8aafeaf60192b082762cbf3e08d57) for more info on forms). A new post resource can be created with the following request +This form shows us how to create new post resources (see [Forms](doc/FORMS.md) for more info). A new post resource can be created with the following request ```sh curl -H "Content-Type: application/json" \ -d '{"title": "hello", "message": "lorem ipsum"}' \ @@ -227,6 +249,9 @@ The response shows us the new resource, with the attributes that we set as well "title": "hello", "message": "lorem ipsum", "_links": { + "profile": { + "href": "http://localhost:3000/doc/profiles/post" + }, "collection": { "href": "http://localhost:3000/posts" }, @@ -242,7 +267,7 @@ The response shows us the new resource, with the attributes that we set as well "curies": [ { "name": "doc", - "href": "http://localhost:3000/doc/post/rels/{rel}", + "href": "http://localhost:3000/doc/profiles/post{#rel}", "templated": true } ] @@ -269,7 +294,7 @@ Response: "curies": [ { "name": "doc", - "href": "http://localhost:3000/doc/post/rels/{rel}", + "href": "http://localhost:3000/doc/profiles/post{#rel}", "templated": true } ] @@ -280,6 +305,9 @@ Response: "title": "hello", "message": "lorem ipsum", "_links": { + "profile": { + "href": "http://localhost:3000/doc/profiles/post" + }, "collection": { "href": "http://localhost:3000/posts" }, @@ -291,7 +319,7 @@ Response: }, "doc:delete": { "href": "http://localhost:3000/posts/1" - }, + } } } ] @@ -308,25 +336,29 @@ shaf generate scaffold post title:string message:string rake db:migrate ``` -## [Upgrading a shaf project](doc/UPGRADE.md) -## [HAL mediatype](doc/HAL.md) -## [Sinatra](doc/SINATRA.md) -## [Generators](doc/GENERATORS.md) -## [Routing/Controllers](doc/ROUTING.md) -## [Models](doc/MODELS.md) -## [Forms](doc/FORMS.md) -## [Serializers](doc/SERIALIZERS.md) -## [Policies](doc/POLICIES.md) -## [Settings](doc/SETTINGS.md) -## [Database](doc/DATABASE.md) -## [Testing](doc/TESTING.md) -## [API Documentation](doc/DOCUMENTATION.md) -## [HTTP Caching](doc/HTTP_CACHING.md) -## [Pagination](doc/PAGINATION.md) -## [ShafClient](doc/SHAF_CLIENT.md) -## [Frontend](doc/FRONTEND.md) -## [Customizations](doc/CUSTOMIZATIONS.md) -## [Business logic](doc/BUSINESS_LOGIC.md) +## Documentation +### [Sinatra](doc/SINATRA.md) +### [HAL mediatype](doc/HAL.md) +### [Mediatype profiles](doc/PROFILES.md) +### [Generators](doc/GENERATORS.md) +### [Routing/Controllers](doc/ROUTING.md) +### [Models](doc/MODELS.md) +### [Forms](doc/FORMS.md) +### [Serializers](doc/SERIALIZERS.md) +### [Policies](doc/POLICIES.md) +### [Authentication](doc/AUTHENTICATION.md) +### [Settings](doc/SETTINGS.md) +### [Database](doc/DATABASE.md) +### [Mediatype profiles](doc/PROFILES.md) +### [API Documentation](doc/DOCUMENTATION.md) +### [HTTP Caching](doc/HTTP_CACHING.md) +### [Pagination](doc/PAGINATION.md) +### [Upgrading a shaf project](doc/UPGRADE.md) +### [Testing](doc/TESTING.md) +### [ShafClient](doc/SHAF_CLIENT.md) +### [Frontend](doc/FRONTEND.md) +### [Customizations](doc/CUSTOMIZATIONS.md) +### [Business logic](doc/BUSINESS_LOGIC.md) ## Contributing diff --git a/doc/AUTHENTICATION.md b/doc/AUTHENTICATION.md index 6b4991a..fbe8b9d 100644 --- a/doc/AUTHENTICATION.md +++ b/doc/AUTHENTICATION.md @@ -1,14 +1,23 @@ ## Authentication Shaf uses a concept of _authenticators_ to handle authentication. Currently the only natively supported authentication scheme is Basic Auth. However support for additional schemes may be added by creating new authenticator classes. -(Please consider contributing to Shaf if you write an authenticator that could be of use to others) - -#### HTTP authentication framework -TODO +(Please consider contributing to Shaf if you write an authenticator that could be of use to others). +See [Customizations](CUSTOMIZATIONS.md) for info about creating authenticators. #### Basic Auth -TODO +To setup authentication with Basic Auth, call the `restricted` class method on `Shaf::Authenticator::BasicAuth`. A 'realm' must be specified using the `realm` keyword argument and a block must be passed in. The block must accept the keyword arguments `user` and `password`. +The return value of the block will be returned from the `current_user` helper. +Depending on how you have configured your user model etc this block will look different. But as an example it should probably look something like this: +```ruby +Shaf::Authenticator::BasicAuth.restricted realm: 'api' do |user:, password:| + return unless user && password + password_hash = Digest::SHA256.hexdigest(password) + User.where(username: user, password_hash: password_hash).first +end +``` + +Now controllers can require user to authenticate, e.g. `authenticate! realm: 'api'`. -#### Creating new authenticators -All authenticators must be subclasses of `Shaf::Authenticator::Base` -TODO +Sometimes resources can be seen by unauthenticated users, but might contain more attributes and/or links when a user has authenticated. (This is often true for the entry point of the api). In this case it's good practice to let clients know how to authenticate by using the _WWW-Authenticate_ header. This can either be done using the non-bang version, e.g `authenticate realm: 'api'`. Or using the `www_authenticate(realm: nil)` helper which also sets the _WWW-Authenticate_ header but without looking up the current user. +If you only have one realm, then a default realm can be configured in the settings yml using the key `default_authentication_realm`. This will make these helper methods operate on the default realm and the `realm` keyword argument may then be left out. +Note: The _WWW-Authenticate_ header is of course also set when a 401 response is returned. diff --git a/doc/CUSTOMIZATIONS.md b/doc/CUSTOMIZATIONS.md index 1779e20..7ff671c 100644 --- a/doc/CUSTOMIZATIONS.md +++ b/doc/CUSTOMIZATIONS.md @@ -67,15 +67,31 @@ end ``` This would require the file `generator_templates/foo_service.erb` to exist in the project root. Executing `shaf generate foo` would then read that template, process it through erb (utilizing any local variables given to `render`) and then create the output file `api/services/foo_service.rb`. +#### Parsers +Shaf parses request payloads using a Parser. It ships with two parsers - one for form data and one for json payloads. The former handles the mediatypes "application/x-www-form-urlencoded" and "multipart/form-data". The latter handles json formatted payloads, e.g. "application/json", and "application/foobar+json". +All Parsers should inherit from `Shaf::Parser::Base` and must either call `::mime_type(key, mime_type)` (where `key` is `Symbol` and `mime_type` is a `String`) or implement `::can_handle?(request)` (where `request` is a `Sinatra::Request` instance). Parsers have `attr_reader`s for `request` and `body` (the input String). All Parsers must also respond to `#call` and the value returned from `#call` will be available in controllers through the `payload` helper. An example Parser that handles xml could look like this: +```ruby +require 'active_support/core_ext/hash' + +class XmlParser < Shaf::Parser::Base + + mime_type :xml, 'application/xml' + + def call + Hash.from_xml(body) + end +end +``` + #### Responders When `#respond_with` is used, the serialization is delegated to the best matching `Responder`. Each `Responder` manages a specific media type. Thus the "best" `Responder` is the one that best matches the request's `Accept` header. Shaf ships with three responders. They support the mediatypes `application/hal+json`, `application/problem+json` and `text/html`. (If the `Accept` header does not match any of those, the default response format will be `application/hal+json`). Each responder is a subclass of `Shaf::Responder::Base`. To add more responders, simply define new subclasses of `Shaf::Responder::Base`. -All responders must implement `#body` and the must call `::mime_type(key, mime_type)` (where `key` is `Symbol` and `mime_type` is a `String`). +All responders must implement `#body` and they must call `::mime_type(key, mime_type)` (where `key` is `Symbol` and `mime_type` is a `String`). `#body` must return the serialized response. -Responders are instantiated with `new(controller, resource, **options)` and they have `attr_readers` for each of those arguments. +Responders are instantiated with `new(controller, resource, **options)` and they have `attr_reader`s for each of those arguments. Responders may override `::can_handle?(resource)`, which is used to decide whether or not a responder is able to process a given object. -Say that you would like to be able to return siren payloads. And you happen to have a `MyCustomSirenSerializer` class that can turn any object into a proper siren payload. Then adding a Siren responder would look like this. +Say that you would like to be able to return Siren payloads. And you happen to have a `MyCustomSirenSerializer` class that can turn any object into a proper siren payload. Then adding a Siren responder would look like this. ```ruby class SirenResponder < Shaf::Responder::Base mime_type :siren, 'application/vnd.siren+json' @@ -87,3 +103,10 @@ end ``` Then, when your controller actions are using `respond_with some_resource_object`. Your client's will be able to choose if they would like the response to be formatted as `application/hal+json` or `application/vnd.siren+json` (by setting the `Accept` header correspondingly). + +#### Authenticators +##### The HTTP authentication framework +Authentication with HTTP works by using the _Authorization_ header. It's value is made up of two parts - a scheme and credentials. + +All authenticators must be subclasses of `Shaf::Authenticator::Base` +TODO diff --git a/doc/DOCUMENTATION.md b/doc/DOCUMENTATION.md index 854ce9d..8edb44f 100644 --- a/doc/DOCUMENTATION.md +++ b/doc/DOCUMENTATION.md @@ -1,3 +1,70 @@ ## API Documentation -Since API clients should basically only have to care about the payloads that are returned from the API, it makes sense to keep the API documentation in the serializer files. Each `attribute`, `link`, `curie` and `embed` should be preceeded with code comments that documents how client should interpret/use it. The comments should be in markdown format. This makes it possible to generate API documentation with `rake doc:generate`. This documentation can then be retrieved from a running server at `/doc/RESOURCE_NAME`, where `RESOURCE_NAME` is the name of the resource to fetch doc for, e.g `curl localhost:3000/doc/post`. +The documentation for the API is generated from serializers (see [Serializers](SERIALIZERS.md)) and profiles (see [Profiles](PROFILES.md)) using the command `shaf generate documentation`. +The generator will output static html files in the public directory (configured in `Shaf::Settings.public_folder`, which defaults to `frontend/assets`). +To view the documentation, start the server and navigate the browser to `/api_doc/index.html`. +The documentation will list all resources (generated from serializers) and all profiles. +Both resources and profiles will look very similar and basically contain the same descriptions. The difference being that resources will show all links defined in the serializer regardless of them being present or not in the profile. Typically, they will contain link relations registered with [IANA](https://www.iana.org/assignments/link-relations/link-relations.xhtml) which may or may not be present in the profile. (Note: profiles might also define more attribute/link relations than the resource is making use of, as is show with the `foobar` rel below). +Consider the following profile and serializer: +```ruby +module Profiles + class Post < Shaf::Profile + name 'post' + + use :delete, from: Shaf::Profiles::ShafBasic + + attribute :message, + type: String, + doc: 'The text content of the post' + + relation :foobar, + type: Integer, + doc: 'Lorem ipsum', + href: '/hello/world', + content_type: 'appliation/hal+json' + + relation :publish, + http_method: :put, + doc: <<~DOC + The link relation 'publish' means that the corresponding post resource + may be requested to be published. To activate this link relation, perform + an HTTP PUT request to the href of this link relation. + DOC + end +end + +class PostSerializer < BaseSerializer + + model Post + policy PostPolicy + profile :post + + attribute :message + + link :self do + post_uri(resource) + end + + link :"edit-form" do + edit_post_uri(resource) + end + + link :delete, curie: :doc do + post_uri(resource) + end + + link :author do + user_uri(resource.user_id) + end + + link :publish do + publish_post_uri(resource) + end +end +``` + +They would produce the following documentation. +Profile: +![Profile documentation](profile_doc.png) +Resource: +![Resource documentation](resource_doc.png) diff --git a/doc/GENERATORS.md b/doc/GENERATORS.md index 2781df4..95898a7 100644 --- a/doc/GENERATORS.md +++ b/doc/GENERATORS.md @@ -1,5 +1,6 @@ ## Generators -Shaf ships with a couple of generators to simplify creation of new files. Each generator has an _identifier_ and they are called with `shaf generate IDENTIFIER` plus zero or more arguments. +Shaf ships with a couple of generators to simplify the creation of new files. Each generator has an _identifier_ and they are called with `shaf generate IDENTIFIER` plus zero or more arguments. +(An _identifier_ is a string of one or more words that uniquely identifies a generator). Generators take the approach of rather generating too much than than too little, with the mindset that it's easier to delete than to write. If you prefer to not generate specs, then pass `--no-specs` as an option. Important: Always run `git [stash|commit]` before you generate new files. Generators may create/modify files and then delegate further work to another generator that happens to fail. In that case the generation is only partly performed and the project is in an unknown state. In such case, you would like to be able to easily restore the previous state (e.g `git checkout -- .`). @@ -30,7 +31,7 @@ A new serializer is generated with the _serializer_ identifier, a resource name ```sh shaf generate serializer some_resource attr1 attr2 ``` -This will add a new serializer, a serializer spec and call the policy generator. +This will add a new serializer, a serializer spec and call the policy generator and the profile generator. #### Policy A new policy is generated with the _policy_ identifier, a resource name and an arbitrary number of attribute arguments. @@ -39,6 +40,13 @@ shaf generate policy some_resource attr1 attr2 ``` This will add a new policy. +#### Profile +A new mediatype profile is generated with the _profile_ identifier, a resource name and an arbitrary number of attribute arguments. +```sh +shaf generate profile some_resource attr1 attr2 +``` +This will add a new profile. + #### Migration Shaf currently supports 5 db migrations to be generated plus the possibility to generate an empty migration. These are: ```sh diff --git a/doc/HAL.md b/doc/HAL.md index cf8fd93..5f53ed3 100644 --- a/doc/HAL.md +++ b/doc/HAL.md @@ -4,35 +4,36 @@ The [HAL](http://stateless.co/hal_specification.html) mediatype is very simple a `_embedded` contains nested resources. A HAL payload may contain a special link with rel _curies_, which is similar to namespaces in XML. Shaf uses a curie called _doc_ and makes it possible to fetch documentation for any link or embedded resources with a rel begining with `doc:`. The href for curies are always templated, meaning that a part of the href (in our case `{rel}`) must be replaced with a value. Here is the empty collection response from the [Getting started](README.md#getting-started) intro ```sh { + "title": "hello", + "message": "lorem ipsum", "_links": { - "self": { - "href": "http://localhost:3000/posts?page=1&per_page=25" + "profile": { + "href": "http://localhost:3000/doc/profiles/post" + }, + "collection": { + "href": "http://localhost:3000/posts" }, - "up": { - "href": "http://localhost:3000/" + "self": { + "href": "http://localhost:3000/posts/1" }, - "create-form": { - "href": "http://localhost:3000/post/form" + "edit-form": { + "href": "http://localhost:3000/posts/1/edit" }, - "doc:author": { - "href": "http://localhost:3000/users/1" + "doc:delete": { + "href": "http://localhost:3000/posts/1" }, "curies": [ { "name": "doc", - "href": "http://localhost:3000/doc/post/rels/{rel}", + "href": "http://localhost:3000/doc/profiles/post{#rel}", "templated": true } ] - }, - "_embedded": { - "posts": [] } } ``` -In the payload above the href of the _doc_ curie is `http://localhost:3000/doc/post/rels/{rel}` and the author link is prefixed with `doc:`. This means that if we would like to find out information about how the `doc:author` link relates to the posts collection we replace `{rel}` with `author` and perform a GET request to this url. +In the payload above the href of the _doc_ curie is `http://localhost:3000/doc/profiles/post{#rel}` and the delete link is prefixed with `doc:`. This means that if we would like to find out information about how the `doc:delete` link relates to the posts collection we replace `{rel}` with `delete` and perform a GET request to this url. ```sh -curl http://localhost:3000/doc/post/rels/author +http://localhost:3000/doc/profiles/post#delete ``` -This documentation is written as code comments in the corresponding serializer. See [Serializers](SERIALIZERS.md) for more info. Before this documentation can be fetched, a rake task to extract the comments needs to be executed, see [API Documentation](DOCUMENTATION.md) for more info. -HAL supports profiles that describes the semantic meaning if of keys/values. Shaf takes advantage of this and uses two profiles. One for describing generic error messages (see [shaf-error media type profile](https://gist.github.com/sammyhenningsson/049d10e2b8978059cde104fc5d6c2d52)) and another for describing forms (see [shaf-form media type profile](https://gist.github.com/sammyhenningsson/39c8aafeaf60192b082762cbf3e08d57)). +As a human its quite easy to guess that the `doc:delete` relation means that we may delete the resource. However, for a client it might not be obvious. Luckily the response from the _GET_ request is formatted as a mediatype profile that describe what the link means. (E.g. clients can automatically know that they should send a _DELETE_ request to the href if this link relation should be activated.) diff --git a/doc/POLICIES.md b/doc/POLICIES.md index 1aba339..4eb4075 100644 --- a/doc/POLICIES.md +++ b/doc/POLICIES.md @@ -16,7 +16,7 @@ end ``` Here `resource` is the object being serialized (in our case the `post` object). Used together with a serializer that specifies links with rels _edit_, _edit-form_ and _delete_, those links will only be serialized when the -block returns `true`. The `resource` method is inherited and general for all policies. To make this a bit prettier +block above returns `true`. The `resource` method is inherited and general for all policies. To make this a bit prettier it's recommended to create an alias for the name of the resource that the policy handles. Policies should also be used in Controllers (through the `authorize_with` class method). Since the links that should be serialized should coincide with which action should be allowed in the controller it makes sense to diff --git a/doc/PROFILES.md b/doc/PROFILES.md new file mode 100644 index 0000000..3255138 --- /dev/null +++ b/doc/PROFILES.md @@ -0,0 +1,107 @@ +## Mediatype profiles +When choosing a mediatype, there are basically two approaches. Select (or invent) a mediatype that match all your requirements or select a generic mediatype that works on a broad range of use cases. +Shaf favors the use of HAL which is a generic mediatype that most certainly will be capable of rendering your resources. The advantage of using a generic (and well-know) mediatype is that it will be easy to find libraries that can serialize and deserialize payloads. The downside is that, even though clients can parse payloads, they wont necessarily understand what they mean. +This is were mediatype profiles comes into use. Profiles describe the attributes of a resource and what you can with it. +Note that mediatype profiles are agnostic to mediatypes, i.e we can use the same profile regardless if clients wants the response formatted as `application/hal+json` or `application/vnd.collection+json` etc. +By inheriting from `Shaf::Profile` we get a DSL for creating mediatype profiles. These profiles can then be serialized into [ALPS](https://tools.ietf.org/html/draft-amundsen-richardson-foster-alps-04) and they are also used to generate human readable documentation. Classes inheriting from `Shaf::Profile` gets the following methods: + - `::name(str)` + Sets the name of the profile. Must be called and `str` must be unique! + - `::doc(str)` + Adds a top level documentation for the profile. + - `::attribute(name, doc:, type: :string, &block)` + Adds an attribute descriptor. A block may be passed in to create nested attributes and relations. + - `::rel(name, **options, &block)` (also aliased as `::relation(name, **options, &block)`. + Adds a link relation descriptor. Supported `options` are: + - `doc` - The documentation/description of the link relation. + - `href` - A resolvable href to another document that describes this link relation. + - `http_method` (or `http_methods`) - An HTTP method (or a list of methods) that are supported for this link relation. + - `content_type` - The format that is expected to be returned from this link relation. (Use with the _Accept_ header) + - `::use(name, from:)` + Used for "including" a descriptor from another profile. `name` is the name of the descriptor and `from` specifies the Ruby class that defines the profile. + - `::example` + TBD: This will be used to add examples of how the profile may be used. Currently this is not used yet, but hopefully this will change in the future. + +#### What to specify +Ideally each attribute should be described with some text (`doc`) and its type. Link relations should either specify a `href` which will be used as the full description for the corresponding link relation. Or they should be described with some text (`doc`) and HTTP method(s). If it's important that clients specify the correct value in the _Accept_ header, then `content_type` should be specified. +Link relations only need to be specified if they are not already registered with [IANA](https://www.iana.org/assignments/link-relations/link-relations.xhtml) (clients are assumed to be aware about link relations registered with IANA). Though it's perfectly fine to describe link relations in IANA and supply a description that better suites your API (your links should not conflict with the IANA registry, but perhaps you could write the documentation in a way that matches your API better, or perhaps you need to specify the HTTP methods that are supported by your API). + +#### An example +```ruby +module Profiles + class Post < Shaf::Profile + name 'post' + + use :delete, from: Shaf::Profiles::ShafBasic + + attribute :message, + type: String, + doc: 'The text content of the post' + + rel :publish, + http_method: :put, + doc: <<~DOC + The link relation 'publish' means that the corresponding post resource + may be requested to be published. To activate this link relation, perform + an HTTP PUT request to the href of this link relation. + DOC + end +end +``` + +The profile above defines the `message` attribute and two link relations - `delete` which is defined in `Shaf::Profiles::ShafBasic` and `publish`. Serialized as `application/alps+json` this profile looks like: +```json +{ + "alps": { + "version": "1.0", + "descriptor": [ + { + "id": "message", + "type": "semantic", + "doc": { + "value": "The text content of the post" + }, + "name": "message" + }, + { + "id": "delete", + "type": "idempotent", + "href": "/doc/profiles/shaf-basic#delete", + "doc": { + "value": "When a resource contains a link with rel 'delete', this\nmeans that the autenticated user (or any user if the\ncurrent user has not been authenticated), may send a\nDELETE request to the href of the link.\nIf a DELETE request is sent to this href then the corresponding\nresource will be deleted.\n" + }, + "name": "delete", + "ext": [ + { + "id": "http_method", + "href": "https://gist.github.com/sammyhenningsson/2103d839eb79a7baf8854bfb96bda7ae", + "value": [ + "DELETE" + ] + } + ] + }, + { + "id": "publish", + "type": "idempotent", + "doc": { + "value": "The link relation 'publish' means that the corresponding post resource\nmay be requested to be published. To activate this link relation, perform\nan HTTP PUT request to the href of this link relation.\n" + }, + "name": "publish", + "ext": [ + { + "id": "http_method", + "href": "https://gist.github.com/sammyhenningsson/2103d839eb79a7baf8854bfb96bda7ae", + "value": [ + "PUT" + ] + } + ] + } + ] + } +} +``` +Note: ALPS is not specific for HTTP and doesn't have native support for specifying HTTP methods. Thus, the _http_method_ extension is used so that we can specify the available HTTP methods. + +The ALPS response is mostly served as machine readble documentation. If we generate api documentation (see [Documentation](DOCUMENTATION.md)), then we get the human friendly version which looks like this: +![Post profile](post_profile.png) diff --git a/doc/ROUTING.md b/doc/ROUTING.md index 4e3daa3..cd4cfd5 100644 --- a/doc/ROUTING.md +++ b/doc/ROUTING.md @@ -9,7 +9,7 @@ When included the module is also extended so all _uri_-/_path_ helpers will be a ##### `::resource_uris_for(name, base: nil, plural_name: nil)` This method creates four pairs of uri helpers and adds them as class methods and instance methods to the caller. The keyword argument `:base` is used to specify a path namespace (such as '/api') that will be prepended to the uri. This can also be used to nest resources (though this is in general considered bad), like `resource_uris_for :post, base: '/users/:id/'`. -The keyword argument `:plural_name` sets the pluralization of the name (when excluded the plural name will be `name` + 's'). +The keyword argument `:plural_name` sets the pluralization of the name (when excluded the plural name will be `name` + 's'). If `plural_name` is the same as `name` then the helper corresponding to the collection will end with `_collection`. ```sh class PostsController < BaseController resource_uris_for :post @@ -33,6 +33,13 @@ The optional `query_params` takes any given keyword arguments and appends a quer ```sh post_path(post, foo: 'bar') # => /posts/5?foo=bar ``` + +If `query_params` contains the key `fragment_id` then that value will be used as a fragment identifier. + +```sh + post_path(post, fragment_id: 'foobar') # => /posts/5#foobar +``` + Each helper also has a __path?_ version that can be used to check if a path matches the one of the helper. If given an argument it is matched against the helpers path else the caller must respond to `request` (returning an object responding to `path_info`). Use cases ```sh UriHelper.post_path? "/posts/" # => false diff --git a/doc/post_profile.png b/doc/post_profile.png new file mode 100644 index 0000000..4ba3f6d Binary files /dev/null and b/doc/post_profile.png differ diff --git a/doc/profile_doc.png b/doc/profile_doc.png new file mode 100644 index 0000000..0b3e159 Binary files /dev/null and b/doc/profile_doc.png differ diff --git a/doc/resource_doc.png b/doc/resource_doc.png new file mode 100644 index 0000000..a1b40cf Binary files /dev/null and b/doc/resource_doc.png differ