├── .gitignore ├── .rspec ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── lib ├── oat.rb ├── oat │ ├── adapter.rb │ ├── adapters │ │ ├── hal.rb │ │ ├── json_api.rb │ │ └── siren.rb │ ├── props.rb │ ├── serializer.rb │ └── version.rb └── support │ └── class_attribute.rb ├── oat.gemspec └── spec ├── adapters ├── hal_spec.rb ├── json_api_spec.rb └── siren_spec.rb ├── fixtures.rb ├── serializer_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | gemfiles/Gemfile*.lock 8 | InstalledFiles 9 | _yardoc 10 | coverage 11 | doc/ 12 | lib/bundler/man 13 | pkg 14 | rdoc 15 | spec/reports 16 | test/tmp 17 | test/version_tmp 18 | tmp 19 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.1.2 5 | - 1.9.3 6 | 7 | matrix: 8 | include: 9 | - rvm: 1.9.3 10 | - rvm: 2.1.2 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [v0.5.2](https://github.com/ismasan/oat/tree/v0.5.2) (2018-02-02) 4 | [Full Changelog](https://github.com/ismasan/oat/compare/v0.5.1...v0.5.2) 5 | 6 | **Merged pull requests:** 7 | 8 | - Remove dependency on ActiveSupport. [\#76](https://github.com/ismasan/oat/pull/76) ([ismasan](https://github.com/ismasan)) 9 | 10 | ## [v0.5.1](https://github.com/ismasan/oat/tree/v0.5.1) (2017-06-06) 11 | [Full Changelog](https://github.com/ismasan/oat/compare/v0.5.0...v0.5.1) 12 | 13 | **Merged pull requests:** 14 | 15 | - Improve serialization performance by avoiding instance\_eval and method\_missing [\#73](https://github.com/ismasan/oat/pull/73) ([ivoanjo](https://github.com/ivoanjo)) 16 | - Remove duplicated license file [\#70](https://github.com/ismasan/oat/pull/70) ([tjmw](https://github.com/tjmw)) 17 | 18 | ## [v0.5.0](https://github.com/ismasan/oat/tree/v0.5.0) (2016-08-24) 19 | [Full Changelog](https://github.com/ismasan/oat/compare/v0.4.7...v0.5.0) 20 | 21 | **Closed issues:** 22 | 23 | - :only option for filtering [\#67](https://github.com/ismasan/oat/issues/67) 24 | 25 | **Merged pull requests:** 26 | 27 | - Add support for multiple schema blocks [\#69](https://github.com/ismasan/oat/pull/69) ([tjmw](https://github.com/tjmw)) 28 | 29 | ## [v0.4.7](https://github.com/ismasan/oat/tree/v0.4.7) (2016-02-29) 30 | [Full Changelog](https://github.com/ismasan/oat/compare/v0.4.6...v0.4.7) 31 | 32 | **Implemented enhancements:** 33 | 34 | - Override context on embedded serializers [\#13](https://github.com/ismasan/oat/issues/13) 35 | 36 | **Closed issues:** 37 | 38 | - The Siren adapter renders multiple rels per link as an array of an array instead of a flat array [\#64](https://github.com/ismasan/oat/issues/64) 39 | - JsonApi adapter generates anything but JSON API conformant document [\#62](https://github.com/ismasan/oat/issues/62) 40 | - Cross-cutting concerns [\#61](https://github.com/ismasan/oat/issues/61) 41 | 42 | **Merged pull requests:** 43 | 44 | - Support for multiple rels specified as an array for a single link. fixes \#64. [\#65](https://github.com/ismasan/oat/pull/65) ([landlessness](https://github.com/landlessness)) 45 | 46 | ## [v0.4.6](https://github.com/ismasan/oat/tree/v0.4.6) (2015-02-07) 47 | [Full Changelog](https://github.com/ismasan/oat/compare/v0.4.5...v0.4.6) 48 | 49 | **Closed issues:** 50 | 51 | - Serialize to vanilla JSON using the same serializer class [\#59](https://github.com/ismasan/oat/issues/59) 52 | - HAL support for multiple link objects per relationship? [\#58](https://github.com/ismasan/oat/issues/58) 53 | - Nested serializers: undefined method [\#56](https://github.com/ismasan/oat/issues/56) 54 | - Array serialization [\#54](https://github.com/ismasan/oat/issues/54) 55 | - Oat, Rails Responders and hypermedia mime types [\#50](https://github.com/ismasan/oat/issues/50) 56 | - is lib/support/class\_attribute necessary? [\#44](https://github.com/ismasan/oat/issues/44) 57 | 58 | **Merged pull requests:** 59 | 60 | - HAL support for an array of linked objects [\#60](https://github.com/ismasan/oat/pull/60) ([abargnesi](https://github.com/abargnesi)) 61 | - Fixing Nested Serializers example [\#57](https://github.com/ismasan/oat/pull/57) ([coderdave](https://github.com/coderdave)) 62 | - Fix spelling in README.md [\#53](https://github.com/ismasan/oat/pull/53) ([killpack](https://github.com/killpack)) 63 | - provide an example of using Rails responders to support requests using a Hypermedia mime type [\#52](https://github.com/ismasan/oat/pull/52) ([apsoto](https://github.com/apsoto)) 64 | - Stop entities/entity from duplicating entries in linked hash. [\#49](https://github.com/ismasan/oat/pull/49) ([dpdawson](https://github.com/dpdawson)) 65 | - Better documentation for Oat::Adapters::JsonAPI\#collection [\#46](https://github.com/ismasan/oat/pull/46) ([emilesilvis](https://github.com/emilesilvis)) 66 | - add the required rel attribute for Siren sub-entities [\#45](https://github.com/ismasan/oat/pull/45) ([apsoto](https://github.com/apsoto)) 67 | 68 | ## [v0.4.5](https://github.com/ismasan/oat/tree/v0.4.5) (2014-07-09) 69 | [Full Changelog](https://github.com/ismasan/oat/compare/v0.4.4...v0.4.5) 70 | 71 | **Closed issues:** 72 | 73 | - Thread Safety [\#39](https://github.com/ismasan/oat/issues/39) 74 | 75 | **Merged pull requests:** 76 | 77 | - Fix serializer ampersand warning [\#43](https://github.com/ismasan/oat/pull/43) ([iainbeeston](https://github.com/iainbeeston)) 78 | - Update build matrix [\#42](https://github.com/ismasan/oat/pull/42) ([iainbeeston](https://github.com/iainbeeston)) 79 | - Update to rspec3 [\#41](https://github.com/ismasan/oat/pull/41) ([iainbeeston](https://github.com/iainbeeston)) 80 | 81 | ## [v0.4.4](https://github.com/ismasan/oat/tree/v0.4.4) (2014-05-26) 82 | [Full Changelog](https://github.com/ismasan/oat/compare/v0.4.3...v0.4.4) 83 | 84 | **Closed issues:** 85 | 86 | - Top level json api meta [\#28](https://github.com/ismasan/oat/issues/28) 87 | 88 | **Merged pull requests:** 89 | 90 | - Add title addribute to siren action's fields [\#38](https://github.com/ismasan/oat/pull/38) ([erezesk](https://github.com/erezesk)) 91 | 92 | ## [v0.4.3](https://github.com/ismasan/oat/tree/v0.4.3) (2014-05-01) 93 | [Full Changelog](https://github.com/ismasan/oat/compare/v0.4.2...v0.4.3) 94 | 95 | **Merged pull requests:** 96 | 97 | - json-api: Don't add templated links to the resource [\#37](https://github.com/ismasan/oat/pull/37) ([kjg](https://github.com/kjg)) 98 | 99 | ## [v0.4.2](https://github.com/ismasan/oat/tree/v0.4.2) (2014-04-29) 100 | [Full Changelog](https://github.com/ismasan/oat/compare/v0.4.1...v0.4.2) 101 | 102 | **Closed issues:** 103 | 104 | - Errors management [\#35](https://github.com/ismasan/oat/issues/35) 105 | 106 | **Merged pull requests:** 107 | 108 | - Add type attribute for siren actions [\#36](https://github.com/ismasan/oat/pull/36) ([erezesk](https://github.com/erezesk)) 109 | 110 | ## [v0.4.1](https://github.com/ismasan/oat/tree/v0.4.1) (2014-04-25) 111 | [Full Changelog](https://github.com/ismasan/oat/compare/v0.4.0...v0.4.1) 112 | 113 | **Merged pull requests:** 114 | 115 | - Add meta property [\#32](https://github.com/ismasan/oat/pull/32) ([ahx](https://github.com/ahx)) 116 | 117 | ## [v0.4.0](https://github.com/ismasan/oat/tree/v0.4.0) (2014-04-07) 118 | [Full Changelog](https://github.com/ismasan/oat/compare/v0.3.0...v0.4.0) 119 | 120 | **Closed issues:** 121 | 122 | - Does this support any type of caching? [\#30](https://github.com/ismasan/oat/issues/30) 123 | 124 | **Merged pull requests:** 125 | 126 | - Don't block NoMethodErrors from raising [\#31](https://github.com/ismasan/oat/pull/31) ([shekibobo](https://github.com/shekibobo)) 127 | 128 | ## [v0.3.0](https://github.com/ismasan/oat/tree/v0.3.0) (2014-03-25) 129 | [Full Changelog](https://github.com/ismasan/oat/compare/v0.2.5...v0.3.0) 130 | 131 | **Closed issues:** 132 | 133 | - How to serialize many records? [\#24](https://github.com/ismasan/oat/issues/24) 134 | 135 | **Merged pull requests:** 136 | 137 | - Don't allow rake 10.2 on ruby 1.8.7 [\#29](https://github.com/ismasan/oat/pull/29) ([kjg](https://github.com/kjg)) 138 | - Json api link templates [\#27](https://github.com/ismasan/oat/pull/27) ([kjg](https://github.com/kjg)) 139 | - Json api attribute links [\#26](https://github.com/ismasan/oat/pull/26) ([kjg](https://github.com/kjg)) 140 | 141 | ## [v0.2.5](https://github.com/ismasan/oat/tree/v0.2.5) (2014-03-20) 142 | [Full Changelog](https://github.com/ismasan/oat/compare/v0.2.4...v0.2.5) 143 | 144 | **Merged pull requests:** 145 | 146 | - Json api nil entities [\#25](https://github.com/ismasan/oat/pull/25) ([kjg](https://github.com/kjg)) 147 | 148 | ## [v0.2.4](https://github.com/ismasan/oat/tree/v0.2.4) (2014-02-24) 149 | [Full Changelog](https://github.com/ismasan/oat/compare/v0.2.3...v0.2.4) 150 | 151 | **Merged pull requests:** 152 | 153 | - Allow for serializing json api resource collections into root key [\#23](https://github.com/ismasan/oat/pull/23) ([kjg](https://github.com/kjg)) 154 | - Json api subent top linked [\#22](https://github.com/ismasan/oat/pull/22) ([kjg](https://github.com/kjg)) 155 | 156 | ## [v0.2.3](https://github.com/ismasan/oat/tree/v0.2.3) (2014-02-17) 157 | [Full Changelog](https://github.com/ismasan/oat/compare/v0.2.2...v0.2.3) 158 | 159 | **Merged pull requests:** 160 | 161 | - Json api sub behaviour [\#20](https://github.com/ismasan/oat/pull/20) ([kjg](https://github.com/kjg)) 162 | - Add version badge to README [\#18](https://github.com/ismasan/oat/pull/18) ([shekibobo](https://github.com/shekibobo)) 163 | 164 | ## [v0.2.2](https://github.com/ismasan/oat/tree/v0.2.2) (2014-02-17) 165 | [Full Changelog](https://github.com/ismasan/oat/compare/v0.1.2...v0.2.2) 166 | 167 | **Closed issues:** 168 | 169 | - Remove activesupport dependency [\#10](https://github.com/ismasan/oat/issues/10) 170 | 171 | **Merged pull requests:** 172 | 173 | - Test more combinations in travis [\#17](https://github.com/ismasan/oat/pull/17) ([kjg](https://github.com/kjg)) 174 | - Serializer from block or class update [\#16](https://github.com/ismasan/oat/pull/16) ([kjg](https://github.com/kjg)) 175 | - Make specs more accurate and using updated syntax [\#15](https://github.com/ismasan/oat/pull/15) ([shekibobo](https://github.com/shekibobo)) 176 | - Better Context [\#14](https://github.com/ismasan/oat/pull/14) ([shekibobo](https://github.com/shekibobo)) 177 | - Less active support [\#11](https://github.com/ismasan/oat/pull/11) ([kjg](https://github.com/kjg)) 178 | 179 | ## [v0.1.2](https://github.com/ismasan/oat/tree/v0.1.2) (2014-02-13) 180 | [Full Changelog](https://github.com/ismasan/oat/compare/v0.1.1...v0.1.2) 181 | 182 | **Closed issues:** 183 | 184 | - Serializing collections [\#9](https://github.com/ismasan/oat/issues/9) 185 | 186 | **Merged pull requests:** 187 | 188 | - Support ruby 1.8.7 [\#12](https://github.com/ismasan/oat/pull/12) ([kjg](https://github.com/kjg)) 189 | 190 | ## [v0.1.1](https://github.com/ismasan/oat/tree/v0.1.1) (2014-01-26) 191 | [Full Changelog](https://github.com/ismasan/oat/compare/v0.1.0...v0.1.1) 192 | 193 | **Merged pull requests:** 194 | 195 | - Don't add invalid link relations to HAL output [\#8](https://github.com/ismasan/oat/pull/8) ([shekibobo](https://github.com/shekibobo)) 196 | 197 | ## [v0.1.0](https://github.com/ismasan/oat/tree/v0.1.0) (2014-01-14) 198 | [Full Changelog](https://github.com/ismasan/oat/compare/v0.0.1...v0.1.0) 199 | 200 | **Closed issues:** 201 | 202 | - DRY property declaration [\#5](https://github.com/ismasan/oat/issues/5) 203 | 204 | **Merged pull requests:** 205 | 206 | - Add Serializer\#map\_properties to DRY property definitions [\#6](https://github.com/ismasan/oat/pull/6) ([shekibobo](https://github.com/shekibobo)) 207 | - Add action support for Siren. [\#4](https://github.com/ismasan/oat/pull/4) ([SebastianEdwards](https://github.com/SebastianEdwards)) 208 | - Don't try to serialize nil with an entity serializer [\#3](https://github.com/ismasan/oat/pull/3) ([shekibobo](https://github.com/shekibobo)) 209 | - Fix a small typo in the README [\#2](https://github.com/ismasan/oat/pull/2) ([stevenharman](https://github.com/stevenharman)) 210 | 211 | ## [v0.0.1](https://github.com/ismasan/oat/tree/v0.0.1) (2013-11-18) 212 | **Merged pull requests:** 213 | 214 | - Fix a few spelling and grammar errors [\#1](https://github.com/ismasan/oat/pull/1) ([leemachin](https://github.com/leemachin)) 215 | 216 | 217 | 218 | \* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in oat.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Ismael Celis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Oat 2 | [![Build Status](https://travis-ci.org/ismasan/oat.png)](https://travis-ci.org/ismasan/oat) 3 | [![Gem Version](https://badge.fury.io/rb/oat.png)](http://badge.fury.io/rb/oat) 4 | 5 | Adapters-based API serializers with Hypermedia support for Ruby apps. Read [the blog post](http://new-bamboo.co.uk/blog/2013/11/21/oat-explicit-media-type-serializers-in-ruby) for context and motivation. 6 | 7 | ## What 8 | 9 | Oat lets you design your API payloads succinctly while conforming to your *media type* of choice (hypermedia or not). 10 | The details of the media type are dealt with by pluggable adapters. 11 | 12 | Oat ships with adapters for HAL, Siren and JsonAPI, and it's easy to write your own. 13 | 14 | ## Serializers 15 | 16 | A serializer describes one or more of your API's *entities*. 17 | 18 | You extend from [Oat::Serializer](https://github.com/ismasan/oat/blob/master/lib/oat/serializer.rb) to define your own serializers. 19 | 20 | ```ruby 21 | require 'oat/adapters/hal' 22 | class ProductSerializer < Oat::Serializer 23 | adapter Oat::Adapters::HAL 24 | 25 | schema do 26 | type "product" 27 | link :self, href: product_url(item) 28 | 29 | properties do |props| 30 | props.title item.title 31 | props.price item.price 32 | props.description item.blurb 33 | end 34 | end 35 | 36 | end 37 | ``` 38 | 39 | Then in your app (for example a Rails controller) 40 | 41 | ```ruby 42 | product = Product.find(params[:id]) 43 | render json: ProductSerializer.new(product) 44 | ``` 45 | 46 | Serializers require a single object as argument, which can be a model instance, a presenter or any other domain object. 47 | 48 | The full serializer signature is `item`, `context`, `adapter_class`. 49 | 50 | * `item` a model or presenter instance. It is available in your serializer's schema as `item`. 51 | * `context` (optional) a context hash that is passed to the serializer and sub-serializers as the `context` variable. Useful if you need to pass request-specific data. 52 | * `adapter_class` (optional) A serializer's adapter can be configured at class-level or passed here to the initializer. Useful if you want to switch adapters based on request data. More on this below. 53 | 54 | ### Defining Properties 55 | 56 | There are a few different ways of defining properties on a serializer. 57 | 58 | Properties can be added explicitly using `property`. In this case, you can map an arbitrary value to an arbitrary key: 59 | 60 | ```ruby 61 | require 'oat/adapters/hal' 62 | class ProductSerializer < Oat::Serializer 63 | adapter Oat::Adapters::HAL 64 | 65 | schema do 66 | type "product" 67 | link :self, href: product_url(item) 68 | 69 | property :title, item.title 70 | property :price, item.price 71 | property :description, item.blurb 72 | property :the_number_one, 1 73 | end 74 | end 75 | ``` 76 | 77 | Similarly, properties can be added within a block using `properties` to be more concise or make the code more readable. Again, these will set arbitrary values for arbitrary keys: 78 | 79 | ```ruby 80 | require 'oat/adapters/hal' 81 | class ProductSerializer < Oat::Serializer 82 | adapter Oat::Adapters::HAL 83 | 84 | schema do 85 | type "product" 86 | link :self, href: product_url(item) 87 | 88 | properties do |p| 89 | p.title item.title 90 | p.price item.price 91 | p.description item.blurb 92 | p.the_number_one 1 93 | end 94 | end 95 | end 96 | ``` 97 | 98 | In many cases, you will want to simply map the properties of `item` to a property in the serializer. This can be easily done using `map_properties`. This method takes a list of method or attribute names to which `item` will respond. Note that you cannot assign arbitrary values and keys using `map_properties` - the serializer will simply add a key and call that method on `item` to assign the value. 99 | 100 | ```ruby 101 | require 'oat/adapters/hal' 102 | class ProductSerializer < Oat::Serializer 103 | adapter Oat::Adapters::HAL 104 | 105 | schema do 106 | type "product" 107 | link :self, href: product_url(item) 108 | 109 | map_properties :title, :price 110 | property :description, item.blurb 111 | property :the_number_one, 1 112 | end 113 | end 114 | ``` 115 | 116 | ### Defining Links 117 | 118 | Links to other resources can be added by using `link` with a name and an options hash. Most adapters expect just an href in the options hash, but some might support additional properties. 119 | Some adapters also suport passing `templated: true` in the options hash to indicate special treatment of a link template. 120 | 121 | 122 | ### Adding meta-information 123 | 124 | You can add meta-information about your JSON document via `meta :property, "value"`. When using the [JsonAPI](http://jsonapi.org/) adapter these properties are rendered in a [top level "meta" node](http://jsonapi.org/format/#document-top-level). When using the HAL or Siren adapters `meta` just acts as an alias to `property`, so the properties are rendered like normal properties. 125 | 126 | 127 | ## Adapters 128 | 129 | Using the included [HAL](http://stateless.co/hal_specification.html) adapter, the `ProductSerializer` above would render the following JSON: 130 | 131 | ```json 132 | { 133 | "_links": { 134 | "self": {"href": "http://example.com/products/1"} 135 | }, 136 | "title": "Some product", 137 | "price": 1000, 138 | "description": "..." 139 | } 140 | ``` 141 | 142 | You can easily swap adapters. The same `ProductSerializer`, this time using the [Siren](https://github.com/kevinswiber/siren) adapter: 143 | 144 | ```ruby 145 | adapter Oat::Adapters::Siren 146 | ``` 147 | 148 | ... Renders this JSON: 149 | 150 | ```json 151 | { 152 | "class": ["product"], 153 | "links": [ 154 | { "rel": [ "self" ], "href": "http://example.com/products/1" } 155 | ], 156 | "properties": { 157 | "title": "Some product", 158 | "price": 1000, 159 | "description": "..." 160 | } 161 | } 162 | ``` 163 | At the moment Oat ships with adapters for [HAL](http://stateless.co/hal_specification.html), [Siren](https://github.com/kevinswiber/siren) and [JsonAPI](http://jsonapi.org/), but it's easy to write your own. 164 | 165 | Note: Oat adapters are not *required* by default. Your code should explicitly require the ones it needs: 166 | 167 | ```ruby 168 | # HAL 169 | require 'oat/adapters/hal' 170 | # Siren 171 | require 'oat/adapters/siren' 172 | # JsonAPI 173 | require 'oat/adapters/json_api' 174 | ``` 175 | 176 | ## Switching adapters dynamically 177 | 178 | Adapters can also be passed as an argument to serializer instances. 179 | 180 | ```ruby 181 | ProductSerializer.new(product, nil, Oat::Adapters::HAL) 182 | ``` 183 | 184 | That means that your app could switch adapters on run time depending, for example, on the request's `Accept` header or anything you need. 185 | 186 | Note: a different library could be written to make adapter-switching auto-magical for different frameworks, for example using [Responders](http://api.rubyonrails.org/classes/ActionController/Responder.html) in Rails. Also see [Rails Integration](#rails-integration). 187 | 188 | ## Nested serializers 189 | 190 | It's common for a media type to include "embedded" entities within a payload. For example an `account` entity may have many `users`. An Oat serializer can inline such relationships: 191 | 192 | ```ruby 193 | class AccountSerializer < Oat::Serializer 194 | adapter Oat::Adapters::HAL 195 | 196 | schema do 197 | property :id, item.id 198 | property :status, item.status 199 | # user entities 200 | entities :users, item.users do |user, user_serializer| 201 | user_serializer.properties do |props| 202 | props.name user.name 203 | props.email user.email 204 | end 205 | end 206 | end 207 | end 208 | ``` 209 | 210 | Another, more reusable option is to use a nested serializer. Instead of a block, you pass another serializer class that will handle serializing `user` entities. 211 | 212 | ```ruby 213 | class AccountSerializer < Oat::Serializer 214 | adapter Oat::Adapters::HAL 215 | 216 | schema do 217 | property :id, item.id 218 | property :status, item.status 219 | # user entities 220 | entities :users, item.users, UserSerializer 221 | end 222 | end 223 | ``` 224 | 225 | And the `UserSerializer` may look like this: 226 | 227 | ```ruby 228 | class UserSerializer < Oat::Serializer 229 | adapter Oat::Adapters::HAL 230 | 231 | schema do 232 | property :name, item.name 233 | property :email, item.name 234 | end 235 | end 236 | ``` 237 | 238 | In the user serializer, `item` refers to the user instance being wrapped by the serializer. 239 | 240 | The bundled hypermedia adapters ship with an `entities` method to add arrays of entities, and an `entity` method to add a single entity. 241 | 242 | ```ruby 243 | # single entity 244 | entity :child, item.child do |child, s| 245 | s.name child.name 246 | s.id child.id 247 | end 248 | 249 | # list of entities 250 | entities :children, item.children do |child, s| 251 | s.name child.name 252 | s.id child.id 253 | end 254 | ``` 255 | 256 | Both can be expressed using a separate serializer: 257 | 258 | ```ruby 259 | # single entity 260 | entity :child, item.child, ChildSerializer 261 | 262 | # list of entities 263 | entities :children, item.children, ChildSerializer 264 | ``` 265 | 266 | The way sub-entities are rendered in the final payload is up to the adapter. In HAL the example above would be: 267 | 268 | ```json 269 | { 270 | ..., 271 | "_embedded": { 272 | "child": {"name": "child's name", "id": 1}, 273 | "children": [ 274 | {"name": "child 2 name", "id": 2}, 275 | {"name": "child 3 name", "id": 3}, 276 | ... 277 | ] 278 | } 279 | } 280 | ``` 281 | 282 | ## Nested serializers when using the `JsonAPI` adapter 283 | 284 | Collections are easy to express in HAL and Siren because they're no different from any other "entity". JsonAPI, however, doesn't work that way. In JsonAPI there's a distinction between "side-loaded" entities and the collection that is the subject of the resource. For this reason a `collection` method was added to the Oat DSL specifically for use with the `JsonAPI` adapter. 285 | 286 | In the `HAL` and `Siren` adapters, `collection` is aliased to `entities`, but in the `JsonAPI` adapter, it sets the resource's main collection array as per the spec. `entities` keep the current behaviour of side-loading entities in the resource. 287 | 288 | ## Subclassing 289 | 290 | Serializers can be subclassed, for example if you want all your serializers to share the same adapter or add shared helper methods. 291 | 292 | ```ruby 293 | class MyAppSerializer < Oat::Serializer 294 | adapter Oat::Adapters::HAL 295 | 296 | protected 297 | 298 | def format_price(price) 299 | Money.new(price, 'GBP').format 300 | end 301 | end 302 | ``` 303 | 304 | ```ruby 305 | class ProductSerializer < MyAppSerializer 306 | schema do 307 | property :title, item.title 308 | property :price, format_price(item.price) 309 | end 310 | end 311 | ``` 312 | 313 | This is useful if you want your serializers to better express your app's domain. For example, a serializer for a social app: 314 | 315 | ```ruby 316 | class UserSerializer < SocialSerializer 317 | schema do 318 | name item.name 319 | email item.email 320 | # friend entities 321 | friends item.friends 322 | end 323 | end 324 | ``` 325 | 326 | The superclass defines the methods `name`, `email` and `friends`, which in turn delegate to the adapter's setters. 327 | 328 | ```ruby 329 | class SocialSerializer < Oat::Serializer 330 | adapter Oat::Adapters::HAL # or whatever 331 | 332 | # friendly setters 333 | protected 334 | 335 | def name(value) 336 | property :name, value 337 | end 338 | 339 | def email(value) 340 | property :email, value 341 | end 342 | 343 | def friends(objects) 344 | entities :friends, objects, FriendSerializer 345 | end 346 | end 347 | ``` 348 | 349 | You can specify multiple schema blocks, including across class hierarchies. This allows us to append schema attributes or override previously defined attributes: 350 | 351 | ```ruby 352 | class ExtendedUserSerializer < UserSerializer 353 | schema do 354 | name item.full_name # name property will now by the user's full name 355 | property :dob, item.dob # additional date of birth attribute 356 | end 357 | end 358 | ``` 359 | 360 | ## URLs 361 | 362 | Hypermedia is all about the URLs linking your resources together. Oat adapters can have methods to declare links in your entity schema but it's up to your code/framework how to create those links. 363 | A simple stand-alone implementation could be: 364 | 365 | ```ruby 366 | class ProductSerializer < Oat::Serializer 367 | adapter Oat::Adapters::HAL 368 | 369 | schema do 370 | link :self, href: product_url(item.id) 371 | ... 372 | end 373 | 374 | protected 375 | 376 | # helper URL method 377 | def product_url(id) 378 | "https://api.com/products/#{id}" 379 | end 380 | end 381 | ``` 382 | 383 | In frameworks like Rails, you'll probably want to use the URL helpers created by the `routes.rb` file. Two options: 384 | 385 | ### Pass a context hash to serializers 386 | 387 | You can pass a context hash as second argument to serializers. This object will be passed to nested serializers too. For example, you can pass the controller instance itself. 388 | 389 | ```ruby 390 | # users_controller.rb 391 | 392 | def show 393 | user = User.find(params[:id]) 394 | render json: UserSerializer.new(user, controller: self) 395 | end 396 | ``` 397 | 398 | Then, in the `UserSerializer`: 399 | 400 | ```ruby 401 | class ProductSerializer < Oat::Serializer 402 | adapter Oat::Adapters::HAL 403 | 404 | schema do 405 | # `context[:controller]` is the controller, which responds to URL helpers. 406 | link :self, href: context[:controller].product_url(item) 407 | ... 408 | end 409 | end 410 | ``` 411 | 412 | The context hash is passed down to each nested serializer called by a parent. In some cases, you might want to include extra context information for one or more nested serializers. This can be done by passing options into your call to `entity` or `entities`. 413 | 414 | ```ruby 415 | class CategorySerializer < Oat::Serializer 416 | adapter Oat::Adapters::HAL 417 | 418 | schema do 419 | map_properties :id, :name 420 | 421 | # category entities 422 | # passing this option ensures that only direct children are embedded within 423 | # the parent serialized category 424 | entities :subcategories, item.subcategories, CategorySerializer, embedded: true if context[:embedded] 425 | end 426 | end 427 | ``` 428 | 429 | The additional options are merged into the current context before being passed down to the nested serializer. 430 | 431 | ### Mixin Rails' routing module 432 | 433 | Alternatively, you can mix in Rails routing helpers directly into your serializers. 434 | 435 | ```ruby 436 | class MyAppParentSerializer < Oat::Serializer 437 | include ActionDispatch::Routing::UrlFor 438 | include Rails.application.routes.url_helpers 439 | def self.default_url_options 440 | Rails.application.routes.default_url_options 441 | end 442 | 443 | adapter Oat::Adapters::HAL 444 | end 445 | ``` 446 | 447 | Then your serializer sub-classes can just use the URL helpers 448 | 449 | ```ruby 450 | class ProductSerializer < MyAppParentSerializer 451 | schema do 452 | # `product_url` is mixed in from Rails' routing system. 453 | link :self, href: product_url(item) 454 | ... 455 | end 456 | end 457 | ``` 458 | 459 | However, since serializers don't have access to the current request, for this to work you must configure each environment's base host. In `config/environments/production.rb`: 460 | 461 | ```ruby 462 | config.after_initialize do 463 | Rails.application.routes.default_url_options[:host] = 'api.com' 464 | end 465 | ``` 466 | 467 | NOTE: Rails URL helpers could be handled by a separate oat-rails gem. 468 | 469 | ## Custom adapters. 470 | 471 | An adapter's primary concern is to abstract away the details of specific media types. 472 | 473 | Methods defined in an adapter are exposed as `schema` setters in your serializers. 474 | Ideally different adapters should expose the same methods so your serializers can switch adapters without loosing compatibility. For example all bundled adapters expose the following methods: 475 | 476 | * `type` The type of the entity. Renders as "class" in Siren, root node name in JsonAPI, not used in HAL. 477 | * `link` Add a link with `rel` and `href`. Renders inside "_links" in HAL, "links" in Siren and JsonAP. 478 | * `property` Add a property to the entity. Top level attributes in HAL and JsonAPI, "properties" node in Siren. 479 | * `properties` Yield a properties object to set many properties at once. 480 | * `entity` Add a single sub-entity. "_embedded" node in HAL, "entities" in Siren, "linked" in JsonAPI. 481 | * `entities` Add a collection of sub-entities. 482 | 483 | You can define these in your own custom adapters if you're using your own media type or need to implement a different spec. 484 | 485 | ```ruby 486 | class CustomAdapter < Oat::Adapter 487 | 488 | def type(*types) 489 | data[:rel] = types 490 | end 491 | 492 | def property(name, value) 493 | data[:attr][name] = value 494 | end 495 | 496 | def entity(name, obj, serializer_class = nil, &block) 497 | data[:nested_documents] = serializer_from_block_or_class(obj, serializer_class, &block).to_hash 498 | end 499 | 500 | ... etc 501 | end 502 | ``` 503 | 504 | An adapter class provides a `data` object (just a Hash) that stores your data in the structure you want. An adapter's public methods are exposed to your serializers. 505 | 506 | ## Unconventional or domain specific adapters 507 | 508 | Although adapters should in general comply with a common interface, you can still create your own domain-specific adapters if you need to. 509 | 510 | Let's say you're working on a media-type specification specializing in describing social networks and want your payload definitions to express the concept of "friendship". You want your serializers to look like: 511 | 512 | ```ruby 513 | class UserSerializer < Oat::Serializer 514 | adapter SocialAdapter 515 | 516 | schema do 517 | name item.name 518 | email item.email 519 | 520 | # Friend entity 521 | friends item.friends do |friend, friend_serializer| 522 | friend_serializer.name friend.name 523 | friend_serializer.email friend.email 524 | end 525 | end 526 | end 527 | ``` 528 | 529 | A custom media type could return JSON looking looking like this: 530 | 531 | ```json 532 | { 533 | "name": "Joe", 534 | "email": "joe@email.com", 535 | "friends": [ 536 | {"name": "Jane", "email":"jane@email.com"}, 537 | ... 538 | ] 539 | } 540 | ``` 541 | 542 | The adapter for that would be: 543 | 544 | ```ruby 545 | class SocialAdapter < Oat::Adapter 546 | 547 | def name(value) 548 | data[:name] = value 549 | end 550 | 551 | def email(value) 552 | data[:email] = value 553 | end 554 | 555 | def friends(friend_list, serializer_class = nil, &block) 556 | data[:friends] = friend_list.map do |obj| 557 | serializer_from_block_or_class(obj, serializer_class, &block).to_hash 558 | end 559 | end 560 | end 561 | ``` 562 | 563 | But you can easily write an adapter that turns your domain-specific serializers into HAL-compliant JSON. 564 | 565 | ```ruby 566 | class SocialHalAdapter < Oat::Adapters::HAL 567 | 568 | def name(value) 569 | property :name, value 570 | end 571 | 572 | def email(value) 573 | property :email, value 574 | end 575 | 576 | def friends(friend_list, serializer_class = nil, &block) 577 | entities :friends, friend_list, serializer_class, &block 578 | end 579 | end 580 | ``` 581 | 582 | The result for the SocialHalAdapter is: 583 | 584 | ```json 585 | { 586 | "name": "Joe", 587 | "email": "joe@email.com", 588 | "_embedded": { 589 | "friends": [ 590 | {"name": "Jane", "email":"jane@email.com"}, 591 | ... 592 | ] 593 | } 594 | } 595 | ``` 596 | 597 | You can take a look at [the built-in Hypermedia adapters](https://github.com/ismasan/oat/tree/master/lib/oat/adapters) for guidance. 598 | 599 | ## Rails Integration 600 | The Rails responder functionality works out of the box with Oat when the 601 | requests specify JSON as their response format via a header 602 | `Accept: application/json` or query parameter `format=json`. 603 | 604 | However, if you want to also support the mime type of your Hypermedia 605 | format of choice, it will require a little bit of code. 606 | 607 | The example below uses Siren, but the same pattern can be used for HAL and 608 | JsonAPI. 609 | 610 | Register the Siren mime-type and a responder: 611 | 612 | ```ruby 613 | # config/initializers/oat.rb 614 | Mime::Type.register 'application/vnd.siren+json', :siren 615 | 616 | ActionController::Renderers.add :siren do |resource, options| 617 | self.content_type ||= Mime[:siren] 618 | resource.to_siren 619 | end 620 | ``` 621 | 622 | In your controller, add `:siren` to the `respond_to`: 623 | 624 | ```ruby 625 | class UsersController < ApplicationController 626 | respond_to :siren, :json 627 | 628 | def show 629 | user = User.find(params[:id]) 630 | respond_with UserSerializer.new(user) 631 | end 632 | end 633 | ``` 634 | 635 | Finally, add a `to_siren` method to your serializer: 636 | 637 | ```ruby 638 | class UserSerializer < Oat::Serializer 639 | adapter Oat::Adapters::Siren 640 | 641 | schema do 642 | property :name, item.name 643 | property :email, item.name 644 | end 645 | 646 | def to_siren 647 | to_json 648 | end 649 | end 650 | ``` 651 | 652 | Now http requests that specify the Siren mime type will work as 653 | expected. 654 | 655 | **NOTE** 656 | The key thing that makes this all work together is that the 657 | object passed to `respond_with` implements a `to_FORMAT` method, where 658 | `FORMAT` is the symbol used to register the mime type and responder 659 | (`:siren`). Without it, Rails will not invoke your responder block. 660 | 661 | ## Installation 662 | 663 | Add this line to your application's Gemfile: 664 | 665 | gem 'oat' 666 | 667 | And then execute: 668 | 669 | $ bundle 670 | 671 | Or install it yourself as: 672 | 673 | $ gem install oat 674 | 675 | ## TODO / contributions welcome 676 | 677 | * JsonAPI top-level meta 678 | * testing module that can be used for testing spec-compliance in user apps? 679 | 680 | ## Contributing 681 | 682 | 1. Fork it 683 | 2. Create your feature branch (`git checkout -b my-new-feature`) 684 | 3. Commit your changes (`git commit -am 'Add some feature'`) 685 | 4. Push to the branch (`git push origin my-new-feature`) 686 | 5. Create new Pull Request 687 | 688 | ## Contributors 689 | 690 | Many thanks to all contributors! https://github.com/ismasan/oat/graphs/contributors 691 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /lib/oat.rb: -------------------------------------------------------------------------------- 1 | require "oat/version" 2 | 3 | module Oat 4 | require 'oat/serializer' 5 | require 'oat/adapter' 6 | end 7 | -------------------------------------------------------------------------------- /lib/oat/adapter.rb: -------------------------------------------------------------------------------- 1 | require 'oat/props' 2 | module Oat 3 | class Adapter 4 | 5 | def initialize(serializer) 6 | @serializer = serializer 7 | @data = Hash.new{|h,k| h[k] = {}} 8 | end 9 | 10 | def to_hash 11 | data 12 | end 13 | 14 | protected 15 | 16 | attr_reader :data, :serializer 17 | 18 | def yield_props(&block) 19 | props = Props.new 20 | serializer.instance_exec(props, &block) 21 | props.to_hash 22 | end 23 | 24 | def serializer_from_block_or_class(obj, serializer_class = nil, context_options = {}, &block) 25 | return nil if obj.nil? 26 | 27 | if block_given? 28 | serializer_class = Class.new(serializer.class) 29 | serializer_class.schemas = [] 30 | serializer_class.schema_methods = [] 31 | serializer_class.adapter self.class 32 | s = serializer_class.new(obj, serializer.context.merge(context_options), serializer.adapter_class, serializer.top) 33 | serializer.instance_exec(obj, s, &block) 34 | s 35 | else 36 | serializer_class.new(obj, serializer.context.merge(context_options), serializer.adapter_class, serializer.top) 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/oat/adapters/hal.rb: -------------------------------------------------------------------------------- 1 | # http://stateless.co/hal_specification.html 2 | module Oat 3 | module Adapters 4 | class HAL < Oat::Adapter 5 | def link(rel, opts = {}) 6 | if opts.is_a?(Array) 7 | data[:_links][rel] = opts.select { |link_obj| link_obj.include?(:href) } 8 | else 9 | data[:_links][rel] = opts if opts[:href] 10 | end 11 | end 12 | 13 | def properties(&block) 14 | data.merge! yield_props(&block) 15 | end 16 | 17 | def property(key, value) 18 | data[key] = value 19 | end 20 | 21 | alias_method :meta, :property 22 | 23 | def rel(rels) 24 | # no-op to maintain interface compatibility with the Siren adapter 25 | end 26 | 27 | def entity(name, obj, serializer_class = nil, context_options = {}, &block) 28 | entity_serializer = serializer_from_block_or_class(obj, serializer_class, context_options, &block) 29 | data[:_embedded][entity_name(name)] = entity_serializer ? entity_serializer.to_hash : nil 30 | end 31 | 32 | def entities(name, collection, serializer_class = nil, context_options = {}, &block) 33 | data[:_embedded][entity_name(name)] = collection.map do |obj| 34 | entity_serializer = serializer_from_block_or_class(obj, serializer_class, context_options, &block) 35 | entity_serializer ? entity_serializer.to_hash : nil 36 | end 37 | end 38 | alias_method :collection, :entities 39 | 40 | def entity_name(name) 41 | # entity name may be an array, but HAL only uses the first 42 | name.respond_to?(:first) ? name.first : name 43 | end 44 | 45 | private :entity_name 46 | 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/oat/adapters/json_api.rb: -------------------------------------------------------------------------------- 1 | # http://jsonapi.org/format/#url-based-json-api 2 | module Oat 3 | module Adapters 4 | 5 | class JsonAPI < Oat::Adapter 6 | 7 | def initialize(*args) 8 | super 9 | @entities = {} 10 | @link_templates = {} 11 | @meta = {} 12 | end 13 | 14 | def rel(rels) 15 | # no-op to maintain interface compatibility with the Siren adapter 16 | end 17 | 18 | def type(*types) 19 | @root_name = pluralize(types.first.to_s).to_sym 20 | end 21 | 22 | def link(rel, opts = {}) 23 | templated = false 24 | if opts.is_a?(Hash) 25 | templated = opts.delete(:templated) 26 | if templated 27 | link_template(rel, opts[:href]) 28 | else 29 | check_link_keys(opts) 30 | end 31 | end 32 | data[:links][rel] = opts unless templated 33 | end 34 | 35 | def check_link_keys(opts) 36 | unsupported_opts = opts.keys - [:href, :id, :ids, :type] 37 | 38 | unless unsupported_opts.empty? 39 | raise ArgumentError, "Unsupported opts: #{unsupported_opts.join(", ")}" 40 | end 41 | if opts.has_key?(:id) && opts.has_key?(:ids) 42 | raise ArgumentError, "ops canot contain both :id and :ids" 43 | end 44 | end 45 | private :check_link_keys 46 | 47 | def link_template(key, value) 48 | @link_templates[key] = value 49 | end 50 | private :link_template 51 | 52 | def properties(&block) 53 | data.merge! yield_props(&block) 54 | end 55 | 56 | def property(key, value) 57 | data[key] = value 58 | end 59 | 60 | def meta(key, value) 61 | @meta[key] = value 62 | end 63 | 64 | def entity(name, obj, serializer_class = nil, context_options = {}, &block) 65 | ent = serializer_from_block_or_class(obj, serializer_class, context_options, &block) 66 | if ent 67 | ent_hash = ent.to_hash 68 | _name = entity_name(name) 69 | link_name = pluralize(_name.to_s).to_sym 70 | data[:links][_name] = ent_hash[:id] 71 | 72 | entity_hash[link_name] ||= [] 73 | unless entity_hash[link_name].include? ent_hash 74 | entity_hash[link_name] << ent_hash 75 | end 76 | end 77 | end 78 | 79 | def entities(name, collection, serializer_class = nil, context_options = {}, &block) 80 | return if collection.nil? || collection.empty? 81 | _name = entity_name(name) 82 | link_name = pluralize(_name.to_s).to_sym 83 | data[:links][link_name] = [] 84 | 85 | collection.each do |obj| 86 | entity_hash[link_name] ||= [] 87 | ent = serializer_from_block_or_class(obj, serializer_class, context_options, &block) 88 | if ent 89 | ent_hash = ent.to_hash 90 | data[:links][link_name] << ent_hash[:id] 91 | unless entity_hash[link_name].include? ent_hash 92 | entity_hash[link_name] << ent_hash 93 | end 94 | end 95 | end 96 | end 97 | 98 | def entity_name(name) 99 | # entity name may be an array, but JSON API only uses the first 100 | name.respond_to?(:first) ? name.first : name 101 | end 102 | 103 | private :entity_name 104 | 105 | def collection(name, collection, serializer_class = nil, context_options = {}, &block) 106 | @treat_as_resource_collection = true 107 | data[:resource_collection] = [] unless data[:resource_collection].is_a?(Array) 108 | 109 | collection.each do |obj| 110 | ent = serializer_from_block_or_class(obj, serializer_class, context_options, &block) 111 | data[:resource_collection] << ent.to_hash if ent 112 | end 113 | end 114 | 115 | def to_hash 116 | raise "JSON API entities MUST define a type. Use type 'user' in your serializers" unless root_name 117 | if serializer.top != serializer 118 | return data 119 | else 120 | h = {} 121 | if @treat_as_resource_collection 122 | h[root_name] = data[:resource_collection] 123 | else 124 | h[root_name] = [data] 125 | end 126 | h[:linked] = @entities if @entities.keys.any? 127 | h[:links] = @link_templates if @link_templates.keys.any? 128 | h[:meta] = @meta if @meta.keys.any? 129 | return h 130 | end 131 | end 132 | 133 | protected 134 | 135 | attr_reader :root_name 136 | 137 | def entity_hash 138 | if serializer.top == serializer 139 | @entities 140 | else 141 | serializer.top.adapter.entity_hash 142 | end 143 | end 144 | 145 | def entity_without_root(obj, serializer_class = nil, &block) 146 | ent = serializer_from_block_or_class(obj, serializer_class, &block) 147 | ent.to_hash.values.first.first if ent 148 | end 149 | 150 | PLURAL = /s$/ 151 | 152 | def pluralize(str) 153 | if str =~ PLURAL 154 | str 155 | else 156 | "#{str}s" 157 | end 158 | end 159 | end 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /lib/oat/adapters/siren.rb: -------------------------------------------------------------------------------- 1 | # https://github.com/kevinswiber/siren 2 | module Oat 3 | module Adapters 4 | class Siren < Oat::Adapter 5 | 6 | def initialize(*args) 7 | super 8 | data[:links] = [] 9 | data[:entities] = [] 10 | data[:actions] = [] 11 | end 12 | 13 | # Sub-Entities have a required rel attribute 14 | # https://github.com/kevinswiber/siren#rel 15 | def rel(rels) 16 | # rel must be an array. 17 | data[:rel] = Array(rels) 18 | end 19 | 20 | def type(*types) 21 | data[:class] = types 22 | end 23 | 24 | def link(rel, opts = {}) 25 | data[:links] << {:rel => [rel].flatten}.merge(opts) 26 | end 27 | 28 | def properties(&block) 29 | data[:properties].merge! yield_props(&block) 30 | end 31 | 32 | def property(key, value) 33 | data[:properties][key] = value 34 | end 35 | 36 | alias_method :meta, :property 37 | 38 | def entity(name, obj, serializer_class = nil, context_options = {}, &block) 39 | ent = serializer_from_block_or_class(obj, serializer_class, context_options, &block) 40 | if ent 41 | # use the name as the sub-entities rel to the parent resource. 42 | ent.rel(name) 43 | ent_hash = ent.to_hash 44 | 45 | unless data[:entities].include? ent_hash 46 | data[:entities] << ent_hash 47 | end 48 | end 49 | end 50 | 51 | def entities(name, collection, serializer_class = nil, context_options = {}, &block) 52 | collection.each do |obj| 53 | entity name, obj, serializer_class, context_options, &block 54 | end 55 | end 56 | 57 | alias_method :collection, :entities 58 | 59 | def action(name, &block) 60 | action = Action.new(name) 61 | block.call(action) 62 | 63 | data[:actions] << action.data 64 | end 65 | 66 | class Action 67 | attr_reader :data 68 | 69 | def initialize(name) 70 | @data = { :name => name, :class => [], :fields => [] } 71 | end 72 | 73 | def klass(value) 74 | data[:class] << value 75 | end 76 | 77 | def field(name, &block) 78 | field = Field.new(name) 79 | block.call(field) 80 | 81 | data[:fields] << field.data 82 | end 83 | 84 | %w(href method title type).each do |attribute| 85 | define_method(attribute) do |value| 86 | data[attribute.to_sym] = value 87 | end 88 | end 89 | 90 | class Field 91 | attr_reader :data 92 | 93 | def initialize(name) 94 | @data = { :name => name, :class => []} 95 | end 96 | 97 | def klass(value) 98 | data[:class] << value 99 | end 100 | 101 | %w(type value title).each do |attribute| 102 | define_method(attribute) do |value| 103 | data[attribute.to_sym] = value 104 | end 105 | end 106 | end 107 | end 108 | 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/oat/props.rb: -------------------------------------------------------------------------------- 1 | module Oat 2 | class Props 3 | 4 | def initialize 5 | @attributes = {} 6 | end 7 | 8 | def id(value) 9 | @attributes[:id] = value 10 | end 11 | 12 | def _from(data) 13 | @attributes = data.to_hash 14 | end 15 | 16 | def method_missing(name, value) 17 | @attributes[name] = value 18 | end 19 | 20 | def to_hash 21 | @attributes 22 | end 23 | 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/oat/serializer.rb: -------------------------------------------------------------------------------- 1 | require 'support/class_attribute' 2 | 3 | module Oat 4 | class Serializer 5 | extend ClassAttribute 6 | class_attribute :_adapter, :logger, :schemas, :schema_methods 7 | 8 | self.schemas = [] 9 | self.schema_methods = [] 10 | 11 | def self.schema(&block) 12 | if block_given? 13 | schema_method_name = :"schema_block_#{self.schema_methods.count}" 14 | 15 | self.schemas += [block] 16 | self.schema_methods += [schema_method_name] 17 | 18 | define_method(schema_method_name, &block) 19 | private(schema_method_name) 20 | end 21 | end 22 | 23 | def self.adapter(adapter_class = nil) 24 | self._adapter = adapter_class if adapter_class 25 | self._adapter 26 | end 27 | 28 | def self.warn(msg) 29 | logger ? logger.warning(msg) : Kernel.warn(msg) 30 | end 31 | 32 | attr_reader :item, :context, :adapter_class, :adapter 33 | 34 | def initialize(item, context = {}, _adapter_class = nil, parent_serializer = nil) 35 | @item, @context = item, context 36 | @parent_serializer = parent_serializer 37 | @adapter_class = _adapter_class || self.class.adapter 38 | @adapter = @adapter_class.new(self) 39 | end 40 | 41 | def top 42 | @top ||= @parent_serializer || self 43 | end 44 | 45 | def method_missing(name, *args, &block) 46 | if adapter.respond_to?(name) 47 | self.class.class_eval <<-RUBY, __FILE__, __LINE__ + 1 48 | private 49 | 50 | def #{name}(*args, &block) 51 | adapter.#{name}(*args, &block) 52 | end 53 | RUBY 54 | 55 | send(name, *args, &block) 56 | else 57 | super 58 | end 59 | end 60 | 61 | def type(*args) 62 | if adapter.respond_to?(:type) && adapter.method(:type).arity != 0 63 | adapter.type(*args) 64 | end 65 | end 66 | 67 | def respond_to_missing?(method_name, include_private = false) 68 | adapter.respond_to? method_name 69 | end 70 | 71 | def to_hash 72 | @to_hash ||= ( 73 | self.class.schema_methods.each do |schema_method_name| 74 | send(schema_method_name) 75 | end 76 | 77 | adapter.to_hash 78 | ) 79 | end 80 | 81 | def map_properties(*args) 82 | args.each { |name| map_property name } 83 | end 84 | 85 | def map_property(name) 86 | value = item.send(name) 87 | property name, value 88 | end 89 | 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/oat/version.rb: -------------------------------------------------------------------------------- 1 | module Oat 2 | VERSION = "0.6.1" 3 | end 4 | -------------------------------------------------------------------------------- /lib/support/class_attribute.rb: -------------------------------------------------------------------------------- 1 | module Oat 2 | module ClassAttribute 3 | def class_attribute(*attrs) 4 | attrs.each do |name| 5 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 6 | def self.#{name}() nil end 7 | 8 | def self.#{name}=(val) 9 | singleton_class.class_eval do 10 | define_method(:#{name}) { val } 11 | end 12 | 13 | if singleton_class? 14 | class_eval do 15 | def #{name} 16 | defined?(@#{name}) ? @#{name} : singleton_class.#{name} 17 | end 18 | end 19 | end 20 | val 21 | end 22 | RUBY 23 | 24 | end 25 | end 26 | 27 | def singleton_class 28 | class << self 29 | self 30 | end 31 | end unless respond_to?(:singleton_class) # exists in 1.9.2 32 | 33 | def singleton_class? 34 | ancestors.first != self 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /oat.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'oat/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "oat" 8 | spec.version = Oat::VERSION 9 | spec.authors = ["Ismael Celis"] 10 | spec.email = ["ismaelct@gmail.com"] 11 | spec.description = %q{Oat helps you separate your API schema definitions from the underlying media type. Media types can be plugged or swapped on demand globally or on the content-negotiation phase} 12 | spec.summary = %q{Adapters-based serializers with Hypermedia support} 13 | spec.homepage = "https://github.com/ismasan/oat" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files`.split($/) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_development_dependency "bundler", "~> 2.1" 22 | spec.add_development_dependency "rake" 23 | spec.add_development_dependency "rspec", ">= 3.0" 24 | spec.add_development_dependency "rspec-its" 25 | end 26 | -------------------------------------------------------------------------------- /spec/adapters/hal_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'oat/adapters/hal' 3 | 4 | describe Oat::Adapters::HAL do 5 | 6 | include Fixtures 7 | 8 | let(:serializer) { serializer_class.new(user, {:name => 'some_controller'}, Oat::Adapters::HAL) } 9 | let(:hash) { serializer.to_hash } 10 | 11 | describe '#to_hash' do 12 | it 'produces a HAL-compliant hash' do 13 | expect(hash).to include( 14 | # properties 15 | :id => user.id, 16 | :name => user.name, 17 | :age => user.age, 18 | :controller_name => 'some_controller', 19 | :message_from_above => nil, 20 | # Meta property 21 | :nation => 'zulu' 22 | ) 23 | 24 | # links 25 | expect(hash.fetch(:_links)).to include(:self => { :href => "http://foo.bar.com/#{user.id}" }) 26 | 27 | # HAL Spec says href is REQUIRED 28 | expect(hash.fetch(:_links)).not_to include(:empty) 29 | expect(hash.fetch(:_embedded)).to include(:manager, :friends) 30 | 31 | # embedded manager 32 | expect(hash.fetch(:_embedded).fetch(:manager)).to include( 33 | :id => manager.id, 34 | :name => manager.name, 35 | :age => manager.age, 36 | :_links => { :self => { :href => "http://foo.bar.com/#{manager.id}" } } 37 | ) 38 | 39 | # embedded friends 40 | expect(hash.fetch(:_embedded).fetch(:friends).size).to be 1 41 | expect(hash.fetch(:_embedded).fetch(:friends).first).to include( 42 | :id => friend.id, 43 | :name => friend.name, 44 | :age => friend.age, 45 | :controller_name => 'some_controller', 46 | :message_from_above => "Merged into parent's context", 47 | :_links => { :self => { :href => "http://foo.bar.com/#{friend.id}" } } 48 | ) 49 | end 50 | 51 | context 'with a nil entity relationship' do 52 | let(:manager) { nil } 53 | 54 | it 'produces a HAL-compliant hash' do 55 | # properties 56 | expect(hash).to include( 57 | :id => user.id, 58 | :name => user.name, 59 | :age => user.age, 60 | :controller_name => 'some_controller', 61 | :message_from_above => nil 62 | ) 63 | 64 | expect(hash.fetch(:_links)).to include(:self => { :href => "http://foo.bar.com/#{user.id}" }) 65 | 66 | # HAL Spec says href is REQUIRED 67 | expect(hash.fetch(:_links)).not_to include(:empty) 68 | expect(hash.fetch(:_embedded)).to include(:manager, :friends) 69 | 70 | expect(hash.fetch(:_embedded).fetch(:manager)).to be_nil 71 | 72 | # embedded friends 73 | expect(hash.fetch(:_embedded).fetch(:friends).size).to be 1 74 | expect(hash.fetch(:_embedded).fetch(:friends).first).to include( 75 | :id => friend.id, 76 | :name => friend.name, 77 | :age => friend.age, 78 | :controller_name => 'some_controller', 79 | :message_from_above => "Merged into parent's context", 80 | :_links => { :self => { :href => "http://foo.bar.com/#{friend.id}" } } 81 | ) 82 | end 83 | end 84 | 85 | let(:array_of_linked_objects_serializer) { 86 | array_of_linked_objects_serializer_class.new(friendly_user, nil, Oat::Adapters::HAL) 87 | } 88 | let(:linked_objects_hash) { array_of_linked_objects_serializer.to_hash } 89 | 90 | context 'with an array of linked objects' do 91 | it 'produces a HAL-compliant hash' do 92 | expect(linked_objects_hash.fetch(:_links).fetch(:related).size).to be 3 93 | expect(linked_objects_hash.fetch(:_links)).to include( 94 | :related => [ 95 | {:type=>"friend", :href=>"http://foo.bar.com/1"}, 96 | {:type=>"friend", :href=>"http://foo.bar.com/2"}, 97 | {:type=>"friend", :href=>"http://foo.bar.com/3"} 98 | ] 99 | ) 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /spec/adapters/json_api_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'oat/adapters/json_api' 3 | 4 | describe Oat::Adapters::JsonAPI do 5 | 6 | include Fixtures 7 | 8 | let(:serializer) { serializer_class.new(user, {:name => 'some_controller'}, Oat::Adapters::JsonAPI) } 9 | let(:hash) { serializer.to_hash } 10 | 11 | describe '#to_hash' do 12 | context 'top level' do 13 | subject(:users){ hash.fetch(:users) } 14 | its(:size) { should eq(1) } 15 | 16 | it 'contains the correct user properties' do 17 | expect(users.first).to include( 18 | :id => user.id, 19 | :name => user.name, 20 | :age => user.age, 21 | :controller_name => 'some_controller', 22 | :message_from_above => nil 23 | ) 24 | end 25 | 26 | it 'contains the correct user links' do 27 | expect(users.first.fetch(:links)).to include( 28 | :self => { 29 | :href => "http://foo.bar.com/#{user.id}" 30 | }, 31 | # these links are added by embedding entities 32 | :manager => manager.id, 33 | :friends => [friend.id] 34 | ) 35 | end 36 | end 37 | 38 | context 'meta' do 39 | subject(:meta) { hash.fetch(:meta) } 40 | 41 | it 'contains meta properties' do 42 | expect(meta[:nation]).to eq('zulu') 43 | end 44 | 45 | context 'without meta' do 46 | let(:serializer_class) { 47 | Class.new(Oat::Serializer) do 48 | schema do 49 | type 'users' 50 | end 51 | end 52 | } 53 | 54 | it 'does not contain meta information' do 55 | expect(hash[:meta]).to be_nil 56 | end 57 | end 58 | end 59 | 60 | context 'linked' do 61 | context 'using #entities' do 62 | subject(:linked_friends){ hash.fetch(:linked).fetch(:friends) } 63 | 64 | its(:size) { should eq(1) } 65 | 66 | it 'contains the correct properties' do 67 | expect(linked_friends.first).to include( 68 | :id => friend.id, 69 | :name => friend.name, 70 | :age => friend.age, 71 | :controller_name => 'some_controller', 72 | :message_from_above => "Merged into parent's context" 73 | ) 74 | end 75 | 76 | it 'contains the correct links' do 77 | expect(linked_friends.first.fetch(:links)).to include( 78 | :self => { 79 | :href => "http://foo.bar.com/#{friend.id}" 80 | } 81 | ) 82 | end 83 | end 84 | 85 | context 'using #entity' do 86 | subject(:linked_managers){ hash.fetch(:linked).fetch(:managers) } 87 | 88 | it "does not duplicate an entity that is associated with 2 objects" do 89 | expect(linked_managers.size).to eq(1) 90 | end 91 | 92 | it "contains the correct properties and links" do 93 | expect(linked_managers.first).to include( 94 | :id => manager.id, 95 | :name => manager.name, 96 | :age => manager.age, 97 | :links => { :self => { :href => "http://foo.bar.com/#{manager.id}"} } 98 | ) 99 | end 100 | end 101 | 102 | context 'with nested entities' do 103 | let(:friend) { user_class.new('Joe', 33, 2, [other_friend]) } 104 | let(:other_friend) { user_class.new('Jack', 28, 4, []) } 105 | 106 | subject(:linked_friends){ hash.fetch(:linked).fetch(:friends) } 107 | its(:size) { should eq(2) } 108 | 109 | it 'has the correct entities' do 110 | expect(linked_friends.map{ |friend| friend.fetch(:id) }).to include(2, 4) 111 | end 112 | end 113 | end 114 | 115 | context 'object links' do 116 | context "as string" do 117 | let(:serializer_class) do 118 | Class.new(Oat::Serializer) do 119 | schema do 120 | type 'users' 121 | link :self, "45" 122 | end 123 | end 124 | end 125 | 126 | it 'renders just the string' do 127 | expect(hash.fetch(:users).first.fetch(:links)).to eq({ 128 | :self => "45" 129 | }) 130 | end 131 | end 132 | 133 | context 'as array' do 134 | let(:serializer_class) do 135 | Class.new(Oat::Serializer) do 136 | schema do 137 | type 'users' 138 | link :self, ["45", "46", "47"] 139 | end 140 | end 141 | end 142 | 143 | it 'renders the array' do 144 | expect(hash.fetch(:users).first.fetch(:links)).to eq({ 145 | :self => ["45", "46", "47"] 146 | }) 147 | end 148 | end 149 | 150 | context 'as hash' do 151 | context 'with single id' do 152 | let(:serializer_class) do 153 | Class.new(Oat::Serializer) do 154 | schema do 155 | type 'users' 156 | link :self, :href => "http://foo.bar.com/#{item.id}", :id => item.id.to_s, :type => 'user' 157 | end 158 | end 159 | end 160 | 161 | it 'renders all the keys' do 162 | expect(hash.fetch(:users).first.fetch(:links)).to eq({ 163 | :self => { 164 | :href => "http://foo.bar.com/#{user.id}", 165 | :id => user.id.to_s, 166 | :type => 'user' 167 | } 168 | }) 169 | end 170 | end 171 | 172 | context 'with ids' do 173 | let(:serializer_class) do 174 | Class.new(Oat::Serializer) do 175 | schema do 176 | type 'users' 177 | link :self, :href => "http://foo.bar.com/1,2,3", :ids => ["1", "2", "3"], :type => 'user' 178 | end 179 | end 180 | end 181 | 182 | it 'renders all the keys' do 183 | expect(hash.fetch(:users).first.fetch(:links)).to eq({ 184 | :self => { 185 | :href => "http://foo.bar.com/1,2,3", 186 | :ids => ["1", "2", "3"], 187 | :type => 'user' 188 | } 189 | }) 190 | end 191 | end 192 | 193 | context 'with id and ids' do 194 | let(:serializer_class) do 195 | Class.new(Oat::Serializer) do 196 | schema do 197 | type 'users' 198 | link :self, :id => "45", :ids => ["1", "2", "3"] 199 | end 200 | end 201 | end 202 | 203 | it "errs" do 204 | expect{hash}.to raise_error(ArgumentError) 205 | end 206 | end 207 | 208 | context 'with invalid keys' do 209 | let(:serializer_class) do 210 | Class.new(Oat::Serializer) do 211 | schema do 212 | type 'users' 213 | link :self, :not_a_valid_key => "value" 214 | end 215 | end 216 | end 217 | 218 | it "errs" do 219 | expect{hash}.to raise_error(ArgumentError) 220 | end 221 | end 222 | end 223 | end 224 | 225 | context 'with a nil entity relationship' do 226 | let(:manager) { nil } 227 | let(:users) { hash.fetch(:users) } 228 | 229 | it 'excludes the entity from user links' do 230 | expect(users.first.fetch(:links)).not_to include(:manager) 231 | end 232 | 233 | it 'excludes the entity from the linked hash' do 234 | expect(hash.fetch(:linked)).not_to include(:managers) 235 | end 236 | end 237 | 238 | context 'with a nil entities relationship' do 239 | let(:user) { user_class.new('Ismael', 35, 1, nil, manager) } 240 | let(:users) { hash.fetch(:users) } 241 | 242 | it 'excludes the entity from user links' do 243 | expect(users.first.fetch(:links)).not_to include(:friends) 244 | end 245 | 246 | it 'excludes the entity from the linked hash' do 247 | expect(hash.fetch(:linked)).not_to include(:friends) 248 | end 249 | end 250 | 251 | context 'when an empty entities relationship' do 252 | let(:user) { user_class.new('Ismael', 35, 1, [], manager) } 253 | let(:users) { hash.fetch(:users) } 254 | 255 | it 'excludes the entity from user links' do 256 | expect(users.first.fetch(:links)).not_to include(:friends) 257 | end 258 | 259 | it 'excludes the entity from the linked hash' do 260 | expect(hash.fetch(:linked)).not_to include(:friends) 261 | end 262 | end 263 | 264 | context 'with an entity collection' do 265 | let(:serializer_collection_class) do 266 | USER_SERIALIZER = serializer_class unless defined?(USER_SERIALIZER) 267 | Class.new(Oat::Serializer) do 268 | schema do 269 | type 'users' 270 | collection :users, item, USER_SERIALIZER 271 | end 272 | end 273 | end 274 | 275 | let(:collection_serializer){ 276 | serializer_collection_class.new( 277 | [user,friend], 278 | {:name => "some_controller"}, 279 | Oat::Adapters::JsonAPI 280 | ) 281 | } 282 | let(:collection_hash) { collection_serializer.to_hash } 283 | 284 | context 'top level' do 285 | subject(:users){ collection_hash.fetch(:users) } 286 | its(:size) { should eq(2) } 287 | 288 | it 'contains the correct first user properties' do 289 | expect(users[0]).to include( 290 | :id => user.id, 291 | :name => user.name, 292 | :age => user.age, 293 | :controller_name => 'some_controller', 294 | :message_from_above => nil 295 | ) 296 | end 297 | 298 | it 'contains the correct second user properties' do 299 | expect(users[1]).to include( 300 | :id => friend.id, 301 | :name => friend.name, 302 | :age => friend.age, 303 | :controller_name => 'some_controller', 304 | :message_from_above => nil 305 | ) 306 | end 307 | 308 | it 'contains the correct user links' do 309 | expect(users.first.fetch(:links)).to include( 310 | :self => {:href => "http://foo.bar.com/#{user.id}"}, 311 | # these links are added by embedding entities 312 | :manager => manager.id, 313 | :friends => [friend.id] 314 | ) 315 | end 316 | 317 | context 'sub entity' do 318 | subject(:linked_managers){ collection_hash.fetch(:linked).fetch(:managers) } 319 | 320 | it "does not duplicate an entity that is associated with multiple objects" do 321 | expect(linked_managers.size).to eq(1) 322 | end 323 | 324 | it "contains the correct properties and links" do 325 | expect(linked_managers.first).to include( 326 | :id => manager.id, 327 | :name => manager.name, 328 | :age => manager.age, 329 | :links => { :self => {:href =>"http://foo.bar.com/#{manager.id}"} } 330 | ) 331 | end 332 | end 333 | end 334 | end 335 | 336 | context 'link_template' do 337 | let(:serializer_class) do 338 | Class.new(Oat::Serializer) do 339 | schema do 340 | type 'users' 341 | link "user.managers", :href => "http://foo.bar.com/{user.id}/managers", :templated => true 342 | link "user.friends", :href => "http://foo.bar.com/{user.id}/friends", :templated => true 343 | end 344 | end 345 | end 346 | 347 | it 'renders them top level' do 348 | expect(hash.fetch(:links)).to eq({ 349 | "user.managers" => "http://foo.bar.com/{user.id}/managers", 350 | "user.friends" => "http://foo.bar.com/{user.id}/friends" 351 | }) 352 | end 353 | 354 | it "doesn't render them as links on the resource" do 355 | expect(hash.fetch(:users).first).to_not have_key(:links) 356 | end 357 | end 358 | end 359 | end 360 | -------------------------------------------------------------------------------- /spec/adapters/siren_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'oat/adapters/siren' 3 | 4 | describe Oat::Adapters::Siren do 5 | 6 | include Fixtures 7 | 8 | let(:serializer) { serializer_class.new(user, {:name => 'some_controller'}, Oat::Adapters::Siren) } 9 | let(:hash) { serializer.to_hash } 10 | 11 | describe '#to_hash' do 12 | it 'produces a Siren-compliant hash' do 13 | expect(hash.fetch(:class)).to match_array(['user']) 14 | 15 | expect(hash.fetch(:properties)).to include( 16 | :id => user.id, 17 | :name => user.name, 18 | :age => user.age, 19 | :controller_name => 'some_controller', 20 | :message_from_above => nil, 21 | # Meta property 22 | :nation => 'zulu' 23 | ) 24 | 25 | expect(hash.fetch(:links).size).to be 2 26 | expect(hash.fetch(:links)).to include( 27 | { :rel => [:self], :href => "http://foo.bar.com/#{user.id}" }, 28 | { :rel => [:empty], :href => nil } 29 | ) 30 | 31 | expect(hash.fetch(:entities).size).to be 2 32 | 33 | # embedded friends 34 | embedded_friends = hash.fetch(:entities).select{ |o| o[:class].include? "user" } 35 | expect(embedded_friends.size).to be 1 36 | expect(embedded_friends.first.fetch(:properties)).to include( 37 | :id => friend.id, 38 | :name => friend.name, 39 | :age => friend.age, 40 | :controller_name => 'some_controller', 41 | :message_from_above => "Merged into parent's context" 42 | ) 43 | expect(embedded_friends.first.fetch(:links).first).to include( 44 | :rel => [:self], 45 | :href => "http://foo.bar.com/#{friend.id}" 46 | ) 47 | 48 | # sub-entity rel is an array, so it may have multiple values 49 | expect(embedded_friends.first.fetch(:rel)).to include(:friends) 50 | expect(embedded_friends.first.fetch(:rel)).to include('http://example.org/rels/person') 51 | 52 | embedded_managers = hash.fetch(:entities).select{ |o| o[:class].include? "manager" } 53 | expect(embedded_managers.size).to be 1 54 | expect(embedded_managers.first.fetch(:properties)).to include( 55 | :id => manager.id, 56 | :name => manager.name, 57 | :age => manager.age 58 | ) 59 | expect(embedded_managers.first.fetch(:links).first).to include( 60 | :rel => [:self], 61 | :href => "http://foo.bar.com/#{manager.id}" 62 | ) 63 | expect(embedded_managers.first.fetch(:rel)).to include(:manager) 64 | 65 | # action close_account 66 | actions = hash.fetch(:actions) 67 | expect(actions.size).to eql(1) 68 | expect(actions.first).to include( 69 | :name => :close_account, 70 | :href => "http://foo.bar.com/#{user.id}/close_account", 71 | :class => ['danger', 'irreversible'], 72 | :method => 'DELETE', 73 | :type => 'application/json' 74 | ) 75 | 76 | expect(actions.first.fetch(:fields)).to include( 77 | :class => ['string'], 78 | :name => :current_password, 79 | :type => :password, 80 | :title => 'enter password:' 81 | ) 82 | end 83 | 84 | context 'with a nil entity relationship' do 85 | let(:manager) { nil } 86 | 87 | it 'produces a Siren-compliant hash' do 88 | expect(hash.fetch(:class)).to match_array(['user']) 89 | 90 | expect(hash.fetch(:properties)).to include( 91 | :id => user.id, 92 | :name => user.name, 93 | :age => user.age, 94 | :controller_name => 'some_controller', 95 | :message_from_above => nil 96 | ) 97 | 98 | expect(hash.fetch(:links).size).to be 2 99 | expect(hash.fetch(:links)).to include( 100 | { :rel => [:self], :href => "http://foo.bar.com/#{user.id}" }, 101 | { :rel => [:empty], :href => nil } 102 | ) 103 | 104 | expect(hash.fetch(:entities).size).to be 1 105 | 106 | # embedded friends 107 | embedded_friends = hash.fetch(:entities).select{ |o| o[:class].include? "user" } 108 | expect(embedded_friends.size).to be 1 109 | expect(embedded_friends.first.fetch(:properties)).to include( 110 | :id => friend.id, 111 | :name => friend.name, 112 | :age => friend.age, 113 | :controller_name => 'some_controller', 114 | :message_from_above => "Merged into parent's context" 115 | ) 116 | expect(embedded_friends.first.fetch(:links).first).to include( 117 | :rel => [:self], 118 | :href => "http://foo.bar.com/#{friend.id}" 119 | ) 120 | 121 | embedded_managers = hash.fetch(:entities).select{ |o| o[:class].include? "manager" } 122 | expect(embedded_managers.size).to be 0 123 | end 124 | end 125 | 126 | context 'with multiple rels specified as an array for a single link' do 127 | let(:serializer_class) do 128 | Class.new(Oat::Serializer) do 129 | schema do 130 | type 'users' 131 | link ['describedby', 'http://rels.foo.bar.com/type'], :href => "http://foo.bar.com/meta/user" 132 | end 133 | end 134 | end 135 | 136 | it 'renders the rels as a Siren-compliant non-nested, flat array' do 137 | expect(hash.fetch(:links)).to include( 138 | {:rel=>["describedby", "http://rels.foo.bar.com/type"], :href=>"http://foo.bar.com/meta/user"} 139 | ) 140 | end 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /spec/fixtures.rb: -------------------------------------------------------------------------------- 1 | module Fixtures 2 | 3 | def self.included(base) 4 | base.let(:user_class) { Struct.new(:name, :age, :id, :friends, :manager) } 5 | base.let(:friend) { user_class.new('Joe', 33, 2, []) } 6 | base.let(:manager) { user_class.new('Jane', 29, 3, [friend]) } 7 | base.let(:user) { user_class.new('Ismael', 35, 1, [friend], manager) } 8 | base.let(:friendly_user) { user_class.new('Jeff', 33, 1, [friend, manager, user]) } 9 | base.let(:serializer_class) do 10 | Class.new(Oat::Serializer) do 11 | klass = self 12 | 13 | schema do 14 | type 'user' if respond_to?(:type) 15 | link :self, :href => url_for(item.id) 16 | link :empty, :href => nil 17 | 18 | meta :nation, 'zulu' 19 | 20 | property :id, item.id 21 | map_properties :name, :age 22 | properties do |attrs| 23 | attrs.controller_name context[:name] 24 | attrs.message_from_above context[:message] 25 | end 26 | 27 | entities [:friends, 'http://example.org/rels/person'], item.friends, klass, :message => "Merged into parent's context" 28 | 29 | entity :manager, item.manager do |manager, s| 30 | s.type 'manager' 31 | s.link :self, :href => url_for(manager.id) 32 | s.properties do |attrs| 33 | attrs.id manager.id 34 | attrs.name manager.name 35 | attrs.age manager.age 36 | end 37 | 38 | entities [:friends, 'http://example.org/rels/person'], item.friends, klass, :message => "Merged into parent's context" 39 | end 40 | 41 | if adapter.respond_to?(:action) 42 | action :close_account do |action| 43 | action.href "http://foo.bar.com/#{item.id}/close_account" 44 | action.klass 'danger' 45 | action.klass 'irreversible' 46 | action.method 'DELETE' 47 | action.type 'application/json' 48 | action.field :current_password do |field| 49 | field.klass 'string' 50 | field.type :password 51 | field.title 'enter password:' 52 | end 53 | end 54 | end 55 | end 56 | 57 | def url_for(id) 58 | "http://foo.bar.com/#{id}" 59 | end 60 | end 61 | end 62 | base.let(:array_of_linked_objects_serializer_class) do 63 | Class.new(Oat::Serializer) do 64 | schema do 65 | type 'user' 66 | 67 | link :self, :href => url_for(item.id) 68 | link :related, item.friends.map { |friend| 69 | {:type => 'friend', :href => url_for(friend.id)} 70 | }.sort_by { |link_obj| 71 | link_obj[:href] 72 | } 73 | end 74 | 75 | def url_for(id) 76 | "http://foo.bar.com/#{id}" 77 | end 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /spec/serializer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Oat::Serializer do 4 | 5 | before do 6 | @adapter_class = Class.new(Oat::Adapter) do 7 | def attributes(&block) 8 | data[:attributes].merge!(yield_props(&block)) 9 | end 10 | 11 | def attribute(key, value) 12 | data[:attributes][key] = value 13 | end 14 | 15 | def link(rel, url) 16 | data[:links][rel] = url 17 | end 18 | end 19 | 20 | @sc = Class.new(Oat::Serializer) do 21 | 22 | schema do 23 | my_attribute 'Hello' 24 | attribute :id, item.id 25 | attributes do |attrs| 26 | attrs.name item.name 27 | attrs.age item.age 28 | attrs.controller_name context[:name] 29 | end 30 | link :self, url_for(item.id) 31 | end 32 | 33 | def url_for(id) 34 | "http://foo.bar.com/#{id}" 35 | end 36 | 37 | def my_attribute(value) 38 | attribute :special, value 39 | end 40 | end 41 | 42 | @sc.adapter @adapter_class 43 | end 44 | 45 | let(:user_class) do 46 | Struct.new(:name, :age, :id, :friends) 47 | end 48 | 49 | let(:user1) { user_class.new('Ismael', 35, 1, []) } 50 | 51 | it 'should have a version number' do 52 | expect(Oat::VERSION).to_not be_nil 53 | end 54 | 55 | describe "#context" do 56 | it "is a hash by default" do 57 | expect(@sc.new(user1).context).to be_a Hash 58 | end 59 | 60 | it "can be set like an options hash" do 61 | serializer = @sc.new(user1, :controller => double(:name => "Fancy")) 62 | expect(serializer.context.fetch(:controller).name).to eq "Fancy" 63 | end 64 | end 65 | 66 | describe '#to_hash' do 67 | it 'builds Hash from item and context with attributes as defined in adapter' do 68 | serializer = @sc.new(user1, :name => 'some_controller') 69 | expect(serializer.to_hash.fetch(:attributes)).to include( 70 | :special => 'Hello', 71 | :id => user1.id, 72 | :name => user1.name, 73 | :age => user1.age, 74 | :controller_name => 'some_controller' 75 | ) 76 | 77 | expect(serializer.to_hash.fetch(:links)).to include( 78 | :self => "http://foo.bar.com/#{user1.id}" 79 | ) 80 | end 81 | 82 | context "when multiple schema blocks are specified across a class hierarchy" do 83 | let(:child_serializer) { 84 | Class.new(@sc) do 85 | schema do 86 | attribute :id_plus_x, "#{item.id}_x" 87 | 88 | attributes do |attrs| 89 | attrs.inherited "true" 90 | end 91 | end 92 | end 93 | } 94 | 95 | it "produces the result of both schema blocks in order" do 96 | serializer = child_serializer.new(user1, :name => "child_controller") 97 | 98 | expect(serializer.to_hash.fetch(:attributes)).to include( 99 | :special => 'Hello', 100 | :id => user1.id, 101 | :id_plus_x => "#{user1.id}_x", 102 | :inherited => "true" 103 | ) 104 | end 105 | 106 | it "does not affect the parent serializer" do 107 | serializer = @sc.new(user1, :name => 'some_controller') 108 | 109 | attributes = serializer.to_hash.fetch(:attributes) 110 | 111 | expect(attributes).to_not have_key(:id_plus_x) 112 | expect(attributes).to_not have_key(:inherited) 113 | end 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'rspec/its' 3 | require 'oat' 4 | require 'fixtures' 5 | --------------------------------------------------------------------------------