diff --git a/.gitignore b/.gitignore index 162ed15..b04a8c8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,11 @@ -*.gem -*.rbc -.bundle -.config -.yardoc -Gemfile.lock -gemfiles/Gemfile*.lock -InstalledFiles -_yardoc -coverage -doc/ -lib/bundler/man -pkg -rdoc -spec/reports -test/tmp -test/version_tmp -tmp +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ + +# rspec failure tracking +.rspec_status diff --git a/.rspec b/.rspec index 8c18f1a..34c5164 100644 --- a/.rspec +++ b/.rspec @@ -1,2 +1,3 @@ --format documentation --color +--require spec_helper diff --git a/.travis.yml b/.travis.yml index c69dc2c..2e65f47 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,5 @@ sudo: false language: ruby rvm: - - 2.1.2 - - 1.9.3 - -matrix: - include: - - rvm: 1.9.3 - - rvm: 2.1.2 + - 2.4.2 +before_install: gem install bundler -v 1.16.2 diff --git a/Gemfile b/Gemfile index a034fdd..7ad2d36 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,10 @@ -source 'https://rubygems.org' +source "https://rubygems.org" + +git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } # Specify your gem's dependencies in oat.gemspec gemspec + +group :test do + gem 'byebug' +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..814030c --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,39 @@ +PATH + remote: . + specs: + oat (0.7.0) + parametric (~> 0.2) + +GEM + remote: https://rubygems.org/ + specs: + byebug (11.0.1) + diff-lcs (1.3) + parametric (0.2.5) + rake (10.5.0) + rspec (3.8.0) + rspec-core (~> 3.8.0) + rspec-expectations (~> 3.8.0) + rspec-mocks (~> 3.8.0) + rspec-core (3.8.0) + rspec-support (~> 3.8.0) + rspec-expectations (3.8.3) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.8.0) + rspec-mocks (3.8.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.8.0) + rspec-support (3.8.0) + +PLATFORMS + ruby + +DEPENDENCIES + bundler (~> 1.16) + byebug + oat! + rake (~> 10.0) + rspec (~> 3.0) + +BUNDLED WITH + 1.16.5 diff --git a/README.md b/README.md index 1bde4cf..9f82716 100644 --- a/README.md +++ b/README.md @@ -1,668 +1,16 @@ # Oat -[![Build Status](https://travis-ci.org/ismasan/oat.png)](https://travis-ci.org/ismasan/oat) -[![Gem Version](https://badge.fury.io/rb/oat.png)](http://badge.fury.io/rb/oat) -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. +Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/oat`. To experiment with that code, run `bin/console` for an interactive prompt. -## What - -Oat lets you design your API payloads succinctly while conforming to your *media type* of choice (hypermedia or not). -The details of the media type are dealt with by pluggable adapters. - -Oat ships with adapters for HAL, Siren and JsonAPI, and it's easy to write your own. - -## Serializers - -A serializer describes one or more of your API's *entities*. - -You extend from [Oat::Serializer](https://github.com/ismasan/oat/blob/master/lib/oat/serializer.rb) to define your own serializers. - -```ruby -require 'oat/adapters/hal' -class ProductSerializer < Oat::Serializer - adapter Oat::Adapters::HAL - - schema do - type "product" - link :self, href: product_url(item) - - properties do |props| - props.title item.title - props.price item.price - props.description item.blurb - end - end - -end -``` - -Then in your app (for example a Rails controller) - -```ruby -product = Product.find(params[:id]) -render json: ProductSerializer.new(product) -``` - -Serializers require a single object as argument, which can be a model instance, a presenter or any other domain object. - -The full serializer signature is `item`, `context`, `adapter_class`. - -* `item` a model or presenter instance. It is available in your serializer's schema as `item`. -* `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. -* `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. - -### Defining Properties - -There are a few different ways of defining properties on a serializer. - -Properties can be added explicitly using `property`. In this case, you can map an arbitrary value to an arbitrary key: - -```ruby -require 'oat/adapters/hal' -class ProductSerializer < Oat::Serializer - adapter Oat::Adapters::HAL - - schema do - type "product" - link :self, href: product_url(item) - - property :title, item.title - property :price, item.price - property :description, item.blurb - property :the_number_one, 1 - end -end -``` - -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: - -```ruby -require 'oat/adapters/hal' -class ProductSerializer < Oat::Serializer - adapter Oat::Adapters::HAL - - schema do - type "product" - link :self, href: product_url(item) - - properties do |p| - p.title item.title - p.price item.price - p.description item.blurb - p.the_number_one 1 - end - end -end -``` - -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. - -```ruby -require 'oat/adapters/hal' -class ProductSerializer < Oat::Serializer - adapter Oat::Adapters::HAL - - schema do - type "product" - link :self, href: product_url(item) - - map_properties :title, :price - property :description, item.blurb - property :the_number_one, 1 - end -end -``` - -### Defining Links - -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. -Some adapters also suport passing `templated: true` in the options hash to indicate special treatment of a link template. - - -### Adding meta-information - -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. - - -## Adapters - -Using the included [HAL](http://stateless.co/hal_specification.html) adapter, the `ProductSerializer` above would render the following JSON: - -```json -{ - "_links": { - "self": {"href": "http://example.com/products/1"} - }, - "title": "Some product", - "price": 1000, - "description": "..." -} -``` - -You can easily swap adapters. The same `ProductSerializer`, this time using the [Siren](https://github.com/kevinswiber/siren) adapter: - -```ruby -adapter Oat::Adapters::Siren -``` - -... Renders this JSON: - -```json -{ - "class": ["product"], - "links": [ - { "rel": [ "self" ], "href": "http://example.com/products/1" } - ], - "properties": { - "title": "Some product", - "price": 1000, - "description": "..." - } -} -``` -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. - -Note: Oat adapters are not *required* by default. Your code should explicitly require the ones it needs: - -```ruby -# HAL -require 'oat/adapters/hal' -# Siren -require 'oat/adapters/siren' -# JsonAPI -require 'oat/adapters/json_api' -``` - -## Switching adapters dynamically - -Adapters can also be passed as an argument to serializer instances. - -```ruby -ProductSerializer.new(product, nil, Oat::Adapters::HAL) -``` - -That means that your app could switch adapters on run time depending, for example, on the request's `Accept` header or anything you need. - -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). - -## Nested serializers - -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: - -```ruby -class AccountSerializer < Oat::Serializer - adapter Oat::Adapters::HAL - - schema do - property :id, item.id - property :status, item.status - # user entities - entities :users, item.users do |user, user_serializer| - user_serializer.properties do |props| - props.name user.name - props.email user.email - end - end - end -end -``` - -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. - -```ruby -class AccountSerializer < Oat::Serializer - adapter Oat::Adapters::HAL - - schema do - property :id, item.id - property :status, item.status - # user entities - entities :users, item.users, UserSerializer - end -end -``` - -And the `UserSerializer` may look like this: - -```ruby -class UserSerializer < Oat::Serializer - adapter Oat::Adapters::HAL - - schema do - property :name, item.name - property :email, item.name - end -end -``` - -In the user serializer, `item` refers to the user instance being wrapped by the serializer. - -The bundled hypermedia adapters ship with an `entities` method to add arrays of entities, and an `entity` method to add a single entity. - -```ruby -# single entity -entity :child, item.child do |child, s| - s.name child.name - s.id child.id -end - -# list of entities -entities :children, item.children do |child, s| - s.name child.name - s.id child.id -end -``` - -Both can be expressed using a separate serializer: - -```ruby -# single entity -entity :child, item.child, ChildSerializer - -# list of entities -entities :children, item.children, ChildSerializer -``` - -The way sub-entities are rendered in the final payload is up to the adapter. In HAL the example above would be: - -```json -{ - ..., - "_embedded": { - "child": {"name": "child's name", "id": 1}, - "children": [ - {"name": "child 2 name", "id": 2}, - {"name": "child 3 name", "id": 3}, - ... - ] - } -} -``` - -## Nested serializers when using the `JsonAPI` adapter - -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. - -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. - -## Subclassing - -Serializers can be subclassed, for example if you want all your serializers to share the same adapter or add shared helper methods. - -```ruby -class MyAppSerializer < Oat::Serializer - adapter Oat::Adapters::HAL - - protected - - def format_price(price) - Money.new(price, 'GBP').format - end -end -``` - -```ruby -class ProductSerializer < MyAppSerializer - schema do - property :title, item.title - property :price, format_price(item.price) - end -end -``` - -This is useful if you want your serializers to better express your app's domain. For example, a serializer for a social app: - -```ruby -class UserSerializer < SocialSerializer - schema do - name item.name - email item.email - # friend entities - friends item.friends - end -end -``` - -The superclass defines the methods `name`, `email` and `friends`, which in turn delegate to the adapter's setters. - -```ruby -class SocialSerializer < Oat::Serializer - adapter Oat::Adapters::HAL # or whatever - - # friendly setters - protected - - def name(value) - property :name, value - end - - def email(value) - property :email, value - end - - def friends(objects) - entities :friends, objects, FriendSerializer - end -end -``` - -You can specify multiple schema blocks, including across class hierarchies. This allows us to append schema attributes or override previously defined attributes: - -```ruby -class ExtendedUserSerializer < UserSerializer - schema do - name item.full_name # name property will now by the user's full name - property :dob, item.dob # additional date of birth attribute - end -end -``` - -## URLs - -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. -A simple stand-alone implementation could be: - -```ruby -class ProductSerializer < Oat::Serializer - adapter Oat::Adapters::HAL - - schema do - link :self, href: product_url(item.id) - ... - end - - protected - - # helper URL method - def product_url(id) - "https://api.com/products/#{id}" - end -end -``` - -In frameworks like Rails, you'll probably want to use the URL helpers created by the `routes.rb` file. Two options: - -### Pass a context hash to serializers - -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. - -```ruby -# users_controller.rb - -def show - user = User.find(params[:id]) - render json: UserSerializer.new(user, controller: self) -end -``` - -Then, in the `UserSerializer`: - -```ruby -class ProductSerializer < Oat::Serializer - adapter Oat::Adapters::HAL - - schema do - # `context[:controller]` is the controller, which responds to URL helpers. - link :self, href: context[:controller].product_url(item) - ... - end -end -``` - -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`. - -```ruby -class CategorySerializer < Oat::Serializer - adapter Oat::Adapters::HAL - - schema do - map_properties :id, :name - - # category entities - # passing this option ensures that only direct children are embedded within - # the parent serialized category - entities :subcategories, item.subcategories, CategorySerializer, embedded: true if context[:embedded] - end -end -``` - -The additional options are merged into the current context before being passed down to the nested serializer. - -### Mixin Rails' routing module - -Alternatively, you can mix in Rails routing helpers directly into your serializers. - -```ruby -class MyAppParentSerializer < Oat::Serializer - include ActionDispatch::Routing::UrlFor - include Rails.application.routes.url_helpers - def self.default_url_options - Rails.application.routes.default_url_options - end - - adapter Oat::Adapters::HAL -end -``` - -Then your serializer sub-classes can just use the URL helpers - -```ruby -class ProductSerializer < MyAppParentSerializer - schema do - # `product_url` is mixed in from Rails' routing system. - link :self, href: product_url(item) - ... - end -end -``` - -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`: - -```ruby -config.after_initialize do - Rails.application.routes.default_url_options[:host] = 'api.com' -end -``` - -NOTE: Rails URL helpers could be handled by a separate oat-rails gem. - -## Custom adapters. - -An adapter's primary concern is to abstract away the details of specific media types. - -Methods defined in an adapter are exposed as `schema` setters in your serializers. -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: - -* `type` The type of the entity. Renders as "class" in Siren, root node name in JsonAPI, not used in HAL. -* `link` Add a link with `rel` and `href`. Renders inside "_links" in HAL, "links" in Siren and JsonAP. -* `property` Add a property to the entity. Top level attributes in HAL and JsonAPI, "properties" node in Siren. -* `properties` Yield a properties object to set many properties at once. -* `entity` Add a single sub-entity. "_embedded" node in HAL, "entities" in Siren, "linked" in JsonAPI. -* `entities` Add a collection of sub-entities. - -You can define these in your own custom adapters if you're using your own media type or need to implement a different spec. - -```ruby -class CustomAdapter < Oat::Adapter - - def type(*types) - data[:rel] = types - end - - def property(name, value) - data[:attr][name] = value - end - - def entity(name, obj, serializer_class = nil, &block) - data[:nested_documents] = serializer_from_block_or_class(obj, serializer_class, &block).to_hash - end - - ... etc -end -``` - -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. - -## Unconventional or domain specific adapters - -Although adapters should in general comply with a common interface, you can still create your own domain-specific adapters if you need to. - -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: - -```ruby -class UserSerializer < Oat::Serializer - adapter SocialAdapter - - schema do - name item.name - email item.email - - # Friend entity - friends item.friends do |friend, friend_serializer| - friend_serializer.name friend.name - friend_serializer.email friend.email - end - end -end -``` - -A custom media type could return JSON looking looking like this: - -```json -{ - "name": "Joe", - "email": "joe@email.com", - "friends": [ - {"name": "Jane", "email":"jane@email.com"}, - ... - ] -} -``` - -The adapter for that would be: - -```ruby -class SocialAdapter < Oat::Adapter - - def name(value) - data[:name] = value - end - - def email(value) - data[:email] = value - end - - def friends(friend_list, serializer_class = nil, &block) - data[:friends] = friend_list.map do |obj| - serializer_from_block_or_class(obj, serializer_class, &block).to_hash - end - end -end -``` - -But you can easily write an adapter that turns your domain-specific serializers into HAL-compliant JSON. - -```ruby -class SocialHalAdapter < Oat::Adapters::HAL - - def name(value) - property :name, value - end - - def email(value) - property :email, value - end - - def friends(friend_list, serializer_class = nil, &block) - entities :friends, friend_list, serializer_class, &block - end -end -``` - -The result for the SocialHalAdapter is: - -```json -{ - "name": "Joe", - "email": "joe@email.com", - "_embedded": { - "friends": [ - {"name": "Jane", "email":"jane@email.com"}, - ... - ] - } -} -``` - -You can take a look at [the built-in Hypermedia adapters](https://github.com/ismasan/oat/tree/master/lib/oat/adapters) for guidance. - -## Rails Integration -The Rails responder functionality works out of the box with Oat when the -requests specify JSON as their response format via a header -`Accept: application/json` or query parameter `format=json`. - -However, if you want to also support the mime type of your Hypermedia -format of choice, it will require a little bit of code. - -The example below uses Siren, but the same pattern can be used for HAL and -JsonAPI. - -Register the Siren mime-type and a responder: - -```ruby -# config/initializers/oat.rb -Mime::Type.register 'application/vnd.siren+json', :siren - -ActionController::Renderers.add :siren do |resource, options| - self.content_type ||= Mime[:siren] - resource.to_siren -end -``` - -In your controller, add `:siren` to the `respond_to`: - -```ruby -class UsersController < ApplicationController - respond_to :siren, :json - - def show - user = User.find(params[:id]) - respond_with UserSerializer.new(user) - end -end -``` - -Finally, add a `to_siren` method to your serializer: - -```ruby -class UserSerializer < Oat::Serializer - adapter Oat::Adapters::Siren - - schema do - property :name, item.name - property :email, item.name - end - - def to_siren - to_json - end -end -``` - -Now http requests that specify the Siren mime type will work as -expected. - -**NOTE** -The key thing that makes this all work together is that the -object passed to `respond_with` implements a `to_FORMAT` method, where -`FORMAT` is the symbol used to register the mime type and responder -(`:siren`). Without it, Rails will not invoke your responder block. +TODO: Delete this and the text above, and describe your gem ## Installation Add this line to your application's Gemfile: - gem 'oat' +```ruby +gem 'oat' +``` And then execute: @@ -672,19 +20,16 @@ Or install it yourself as: $ gem install oat -## TODO / contributions welcome +## Usage -* JsonAPI top-level meta -* testing module that can be used for testing spec-compliance in user apps? +TODO: Write usage instructions here -## Contributing +## Development + +After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. -1. Fork it -2. Create your feature branch (`git checkout -b my-new-feature`) -3. Commit your changes (`git commit -am 'Add some feature'`) -4. Push to the branch (`git push origin my-new-feature`) -5. Create new Pull Request +To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). -## Contributors +## Contributing -Many thanks to all contributors! https://github.com/ismasan/oat/graphs/contributors +Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/oat. diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..aacc5ef --- /dev/null +++ b/bin/console @@ -0,0 +1,14 @@ +#!/usr/bin/env ruby + +require "bundler/setup" +require "oat" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require "irb" +IRB.start(__FILE__) diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/lib/oat.rb b/lib/oat.rb index dea26e0..47c4119 100644 --- a/lib/oat.rb +++ b/lib/oat.rb @@ -1,6 +1,6 @@ require "oat/version" +require "oat/serializer" module Oat - require 'oat/serializer' - require 'oat/adapter' + # Your code goes here... end diff --git a/lib/oat/adapter.rb b/lib/oat/adapter.rb deleted file mode 100644 index 499179f..0000000 --- a/lib/oat/adapter.rb +++ /dev/null @@ -1,40 +0,0 @@ -require 'oat/props' -module Oat - class Adapter - - def initialize(serializer) - @serializer = serializer - @data = Hash.new{|h,k| h[k] = {}} - end - - def to_hash - data - end - - protected - - attr_reader :data, :serializer - - def yield_props(&block) - props = Props.new - serializer.instance_exec(props, &block) - props.to_hash - end - - def serializer_from_block_or_class(obj, serializer_class = nil, context_options = {}, &block) - return nil if obj.nil? - - if block_given? - serializer_class = Class.new(serializer.class) - serializer_class.schemas = [] - serializer_class.schema_methods = [] - serializer_class.adapter self.class - s = serializer_class.new(obj, serializer.context.merge(context_options), serializer.adapter_class, serializer.top) - serializer.instance_exec(obj, s, &block) - s - else - serializer_class.new(obj, serializer.context.merge(context_options), serializer.adapter_class, serializer.top) - end - end - end -end diff --git a/lib/oat/adapters/hal.rb b/lib/oat/adapters/hal.rb deleted file mode 100644 index ebebc53..0000000 --- a/lib/oat/adapters/hal.rb +++ /dev/null @@ -1,49 +0,0 @@ -# http://stateless.co/hal_specification.html -module Oat - module Adapters - class HAL < Oat::Adapter - def link(rel, opts = {}) - if opts.is_a?(Array) - data[:_links][rel] = opts.select { |link_obj| link_obj.include?(:href) } - else - data[:_links][rel] = opts if opts[:href] - end - end - - def properties(&block) - data.merge! yield_props(&block) - end - - def property(key, value) - data[key] = value - end - - alias_method :meta, :property - - def rel(rels) - # no-op to maintain interface compatibility with the Siren adapter - end - - def entity(name, obj, serializer_class = nil, context_options = {}, &block) - entity_serializer = serializer_from_block_or_class(obj, serializer_class, context_options, &block) - data[:_embedded][entity_name(name)] = entity_serializer ? entity_serializer.to_hash : nil - end - - def entities(name, collection, serializer_class = nil, context_options = {}, &block) - data[:_embedded][entity_name(name)] = collection.map do |obj| - entity_serializer = serializer_from_block_or_class(obj, serializer_class, context_options, &block) - entity_serializer ? entity_serializer.to_hash : nil - end - end - alias_method :collection, :entities - - def entity_name(name) - # entity name may be an array, but HAL only uses the first - name.respond_to?(:first) ? name.first : name - end - - private :entity_name - - end - end -end diff --git a/lib/oat/adapters/json_api.rb b/lib/oat/adapters/json_api.rb deleted file mode 100644 index 9c72515..0000000 --- a/lib/oat/adapters/json_api.rb +++ /dev/null @@ -1,161 +0,0 @@ -# http://jsonapi.org/format/#url-based-json-api -module Oat - module Adapters - - class JsonAPI < Oat::Adapter - - def initialize(*args) - super - @entities = {} - @link_templates = {} - @meta = {} - end - - def rel(rels) - # no-op to maintain interface compatibility with the Siren adapter - end - - def type(*types) - @root_name = pluralize(types.first.to_s).to_sym - end - - def link(rel, opts = {}) - templated = false - if opts.is_a?(Hash) - templated = opts.delete(:templated) - if templated - link_template(rel, opts[:href]) - else - check_link_keys(opts) - end - end - data[:links][rel] = opts unless templated - end - - def check_link_keys(opts) - unsupported_opts = opts.keys - [:href, :id, :ids, :type] - - unless unsupported_opts.empty? - raise ArgumentError, "Unsupported opts: #{unsupported_opts.join(", ")}" - end - if opts.has_key?(:id) && opts.has_key?(:ids) - raise ArgumentError, "ops canot contain both :id and :ids" - end - end - private :check_link_keys - - def link_template(key, value) - @link_templates[key] = value - end - private :link_template - - def properties(&block) - data.merge! yield_props(&block) - end - - def property(key, value) - data[key] = value - end - - def meta(key, value) - @meta[key] = value - end - - def entity(name, obj, serializer_class = nil, context_options = {}, &block) - ent = serializer_from_block_or_class(obj, serializer_class, context_options, &block) - if ent - ent_hash = ent.to_hash - _name = entity_name(name) - link_name = pluralize(_name.to_s).to_sym - data[:links][_name] = ent_hash[:id] - - entity_hash[link_name] ||= [] - unless entity_hash[link_name].include? ent_hash - entity_hash[link_name] << ent_hash - end - end - end - - def entities(name, collection, serializer_class = nil, context_options = {}, &block) - return if collection.nil? || collection.empty? - _name = entity_name(name) - link_name = pluralize(_name.to_s).to_sym - data[:links][link_name] = [] - - collection.each do |obj| - entity_hash[link_name] ||= [] - ent = serializer_from_block_or_class(obj, serializer_class, context_options, &block) - if ent - ent_hash = ent.to_hash - data[:links][link_name] << ent_hash[:id] - unless entity_hash[link_name].include? ent_hash - entity_hash[link_name] << ent_hash - end - end - end - end - - def entity_name(name) - # entity name may be an array, but JSON API only uses the first - name.respond_to?(:first) ? name.first : name - end - - private :entity_name - - def collection(name, collection, serializer_class = nil, context_options = {}, &block) - @treat_as_resource_collection = true - data[:resource_collection] = [] unless data[:resource_collection].is_a?(Array) - - collection.each do |obj| - ent = serializer_from_block_or_class(obj, serializer_class, context_options, &block) - data[:resource_collection] << ent.to_hash if ent - end - end - - def to_hash - raise "JSON API entities MUST define a type. Use type 'user' in your serializers" unless root_name - if serializer.top != serializer - return data - else - h = {} - if @treat_as_resource_collection - h[root_name] = data[:resource_collection] - else - h[root_name] = [data] - end - h[:linked] = @entities if @entities.keys.any? - h[:links] = @link_templates if @link_templates.keys.any? - h[:meta] = @meta if @meta.keys.any? - return h - end - end - - protected - - attr_reader :root_name - - def entity_hash - if serializer.top == serializer - @entities - else - serializer.top.adapter.entity_hash - end - end - - def entity_without_root(obj, serializer_class = nil, &block) - ent = serializer_from_block_or_class(obj, serializer_class, &block) - ent.to_hash.values.first.first if ent - end - - PLURAL = /s$/ - - def pluralize(str) - if str =~ PLURAL - str - else - "#{str}s" - end - end - end - end -end diff --git a/lib/oat/adapters/siren.rb b/lib/oat/adapters/siren.rb deleted file mode 100644 index be4cc03..0000000 --- a/lib/oat/adapters/siren.rb +++ /dev/null @@ -1,111 +0,0 @@ -# https://github.com/kevinswiber/siren -module Oat - module Adapters - class Siren < Oat::Adapter - - def initialize(*args) - super - data[:links] = [] - data[:entities] = [] - data[:actions] = [] - end - - # Sub-Entities have a required rel attribute - # https://github.com/kevinswiber/siren#rel - def rel(rels) - # rel must be an array. - data[:rel] = Array(rels) - end - - def type(*types) - data[:class] = types - end - - def link(rel, opts = {}) - data[:links] << {:rel => [rel].flatten}.merge(opts) - end - - def properties(&block) - data[:properties].merge! yield_props(&block) - end - - def property(key, value) - data[:properties][key] = value - end - - alias_method :meta, :property - - def entity(name, obj, serializer_class = nil, context_options = {}, &block) - ent = serializer_from_block_or_class(obj, serializer_class, context_options, &block) - if ent - # use the name as the sub-entities rel to the parent resource. - ent.rel(name) - ent_hash = ent.to_hash - - unless data[:entities].include? ent_hash - data[:entities] << ent_hash - end - end - end - - def entities(name, collection, serializer_class = nil, context_options = {}, &block) - collection.each do |obj| - entity name, obj, serializer_class, context_options, &block - end - end - - alias_method :collection, :entities - - def action(name, &block) - action = Action.new(name) - block.call(action) - - data[:actions] << action.data - end - - class Action - attr_reader :data - - def initialize(name) - @data = { :name => name, :class => [], :fields => [] } - end - - def klass(value) - data[:class] << value - end - - def field(name, &block) - field = Field.new(name) - block.call(field) - - data[:fields] << field.data - end - - %w(href method title type).each do |attribute| - define_method(attribute) do |value| - data[attribute.to_sym] = value - end - end - - class Field - attr_reader :data - - def initialize(name) - @data = { :name => name, :class => []} - end - - def klass(value) - data[:class] << value - end - - %w(type value title).each do |attribute| - define_method(attribute) do |value| - data[attribute.to_sym] = value - end - end - end - end - - end - end -end diff --git a/lib/oat/props.rb b/lib/oat/props.rb deleted file mode 100644 index 6e40c6f..0000000 --- a/lib/oat/props.rb +++ /dev/null @@ -1,25 +0,0 @@ -module Oat - class Props - - def initialize - @attributes = {} - end - - def id(value) - @attributes[:id] = value - end - - def _from(data) - @attributes = data.to_hash - end - - def method_missing(name, value) - @attributes[name] = value - end - - def to_hash - @attributes - end - - end -end diff --git a/lib/oat/serializer.rb b/lib/oat/serializer.rb index e1052d5..ae179c8 100644 --- a/lib/oat/serializer.rb +++ b/lib/oat/serializer.rb @@ -1,91 +1,259 @@ -require 'support/class_attribute' +require 'parametric' +require 'delegate' # needed in older Rubies for SimpleDelegator module Oat - class Serializer - extend ClassAttribute - class_attribute :_adapter, :logger, :schemas, :schema_methods + NoMethodError = Class.new(::NoMethodError) - self.schemas = [] - self.schema_methods = [] + class DefaultPresenter < SimpleDelegator + def initialize(item, context) + super item + @context = context + end - def self.schema(&block) - if block_given? - schema_method_name = :"schema_block_#{self.schema_methods.count}" + private + attr_reader :context - self.schemas += [block] - self.schema_methods += [schema_method_name] + def item + __getobj__ + end + end - define_method(schema_method_name, &block) - private(schema_method_name) + class Hal + def self.call(data) + ents = data[:entities].each_with_object({}) do |(key, val), obj| + obj[key] = if val.is_a?(Array) + val.map{|v| call(v) } + else + call(val) + end end - end - def self.adapter(adapter_class = nil) - self._adapter = adapter_class if adapter_class - self._adapter + out = data[:properties].dup + + if data[:links].any? + out[:_links] = data[:links] + end + + if ents.any? + out.merge( + _embedded: ents + ) + else + out + end end + end - def self.warn(msg) - logger ? logger.warning(msg) : Kernel.warn(msg) + class Definition + attr_reader :schema, :props_schema, :entities_schema, :links_schema + + def initialize + @schema = Parametric::Schema.new + @props_schema = Parametric::Schema.new + @entities_schema = Parametric::Schema.new + @links_schema = Parametric::Schema.new + @schema.field(:properties).type(:object).schema(@props_schema) + @schema.field(:entities).type(:object).schema(@entities_schema) + @schema.field(:links).type(:object).schema(@links_schema) end - attr_reader :item, :context, :adapter_class, :adapter + def property(key, opts = {}) + field = props_schema.field(key) + field.meta(from: opts.fetch(:from, key), if: opts[:if]) + field.meta(helper: opts[:helper]) if opts[:helper] + field.type(opts[:type]) if opts[:type] + ex = opts.fetch(:example, "example #{key}") + field.meta(example: ex) + field + end - def initialize(item, context = {}, _adapter_class = nil, parent_serializer = nil) - @item, @context = item, context - @parent_serializer = parent_serializer - @adapter_class = _adapter_class || self.class.adapter - @adapter = @adapter_class.new(self) + def entities(key, opts = {}, &block) + define_entity key, :array, opts, &block end - def top - @top ||= @parent_serializer || self + def entity(key, opts = {}, &block) + define_entity key, :object, opts, &block end - def method_missing(name, *args, &block) - if adapter.respond_to?(name) - self.class.class_eval <<-RUBY, __FILE__, __LINE__ + 1 - private + def link(rel_name, opts = {}) + field = links_schema.field(rel_name).type(:object) + from = opts.delete(:from) + helper = opts.delete(:helper) + example = opts.delete(:example) + if !from && !helper + raise "link '#{rel_name}' must be defined with either :from or :helper options" + end - def #{name}(*args, &block) - adapter.#{name}(*args, &block) - end - RUBY + field.meta(from: from) if from + field.meta(helper: helper) if helper + field.meta(example: example) if example + field.meta(link_options: opts) + end + + private - send(name, *args, &block) + def define_entity(key, type, opts = {}, &block) + field = entities_schema.field(key).type(type) + field.meta(from: opts.fetch(:from, key), if: opts[:if]) + if !opts[:with] && !block_given? + raise "entities require a schema definition as a block or serializer class" + elsif block_given? # sub-serialier from block + sub = Class.new(Serializer) + block.call sub._definition + field.schema(sub.schema).meta(with: sub) else - super + field.schema(opts[:with].schema).meta(with: opts[:with]) end + + field end + end - def type(*args) - if adapter.respond_to?(:type) && adapter.method(:type).arity != 0 - adapter.type(*args) + class Serializer + def self.adapter(adpt = nil) + if adpt + @adapter = adpt end + + @adapter || Hal + end + + def self.serialize(item, adapter: self.adapter, context: nil) + _, pr = presenters.find{|(type, p)| type === item} + pr = presenters[:default] unless pr + item = pr.new(item, context) if pr + new(item, adapter: adapter, context: context).to_h end - def respond_to_missing?(method_name, include_private = false) - adapter.respond_to? method_name + def self._definition + @_definition ||= Definition.new end - def to_hash - @to_hash ||= ( - self.class.schema_methods.each do |schema_method_name| - send(schema_method_name) + def self.schema(&block) + _definition.instance_eval(&block) if block_given? + _definition.schema + end + + def self.example(adapter: self.adapter) + out = _definition.schema.walk(:example).output + out[:links] = _definition.links_schema.walk do |field| + href = field.meta_data[:example] || "https://api.com/#{field.key}" + field.meta_data.fetch(:link_options, {}).merge(href: href) + end.output + + adapter.call(out) + end + + def self.presenters + @presenters ||= {} + end + + def self.present(presenter = nil, type: :default, &block) + if !presenter && !block_given? + raise "Serializer.present expects either a block or a presenter class" + end + + if block_given? + presenter = if parent = presenters[type] # subclass + Class.new(parent, &block) + else + Class.new(DefaultPresenter, &block) + end + end + + presenters[type] = presenter + end + + def self.inherited(subclass) + presenters.each do |key, pr| + subclass.present(pr, type: key) + end + end + + def initialize(item, adapter: self.class.adapter, context: nil) + @item = item + @adapter = adapter + @context = context + end + + def to_h + result = resolve + if result.errors.any? + raise "has errors #{result.errors.inspect}" + end + + adapter.call(result.output) + end + + protected + + def resolve + data = coerce(item, self.class._definition) + self.class._definition.schema.resolve(data) + end + + private + attr_reader :item, :adapter, :context + + def coerce(item, definition) + out = {} + out[:links] = definition.links_schema.fields.each_with_object({}) do |(key, field), obj| + href = invoke(item, field) + opts = field.meta_data.fetch(:link_options, {}) + obj[key] = opts.merge(href: href) + end + + out[:properties] = definition.props_schema.fields.each_with_object({}) do |(key, field), obj| + obj[key] = invoke(item, field) if include_field?(item, field) + end + + out[:entities] = definition.entities_schema.fields.each_with_object({}) do |(key, field), obj| + src = invoke(item, field) + if field.meta_data[:type] == :array + src = [src].flatten + if include_field?(src, field) + obj[key] = src.map do |sr| + sub_output(field.meta_data[:with], sr) + end + end + elsif include_field?(src, field) + obj[key] = sub_output(field.meta_data[:with], src) end + end - adapter.to_hash - ) + out end - def map_properties(*args) - args.each { |name| map_property name } + def sub_output(serializer_klass, sub_item) + serializer_klass.new(sub_item, adapter: adapter, context: context).resolve.output end - def map_property(name) - value = item.send(name) - property name, value + def include_field?(item, field) + condition = field.meta_data[:if] + case condition + when Symbol + item.public_send(condition) + else + true + end end + def invoke(item, field) + helper = field.meta_data[:helper] + if helper + if respond_to?(helper) + return public_send(helper, item) + else + raise NoMethodError, "#{self.class.name} is expected to respond to ##{helper}" + end + end + + method_name = field.meta_data[:from] + if item.respond_to?(method_name) + return item.public_send(method_name) + else + raise NoMethodError, "#{self.class.name} expects #{item.inspect} to respond to ##{method_name}" + end + end end end diff --git a/lib/oat/version.rb b/lib/oat/version.rb index 9a9266a..29746a0 100644 --- a/lib/oat/version.rb +++ b/lib/oat/version.rb @@ -1,3 +1,3 @@ module Oat - VERSION = "0.6.0" + VERSION = "0.7.0" end diff --git a/lib/support/class_attribute.rb b/lib/support/class_attribute.rb deleted file mode 100644 index 6766d35..0000000 --- a/lib/support/class_attribute.rb +++ /dev/null @@ -1,39 +0,0 @@ -module Oat - module ClassAttribute - def class_attribute(*attrs) - attrs.each do |name| - class_eval <<-RUBY, __FILE__, __LINE__ + 1 - def self.#{name}() nil end - - def self.#{name}=(val) - singleton_class.class_eval do - define_method(:#{name}) { val } - end - - if singleton_class? - class_eval do - def #{name} - defined?(@#{name}) ? @#{name} : singleton_class.#{name} - end - end - end - val - end - RUBY - - end - end - - private - - def singleton_class - class << self - self - end - end unless respond_to?(:singleton_class) # exists in 1.9.2 - - def singleton_class? - ancestors.first != self - end - end -end diff --git a/oat.gemspec b/oat.gemspec index 159ce64..4e11a34 100644 --- a/oat.gemspec +++ b/oat.gemspec @@ -1,25 +1,39 @@ -# coding: utf-8 -lib = File.expand_path('../lib', __FILE__) + +lib = File.expand_path("../lib", __FILE__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) -require 'oat/version' +require "oat/version" Gem::Specification.new do |spec| spec.name = "oat" spec.version = Oat::VERSION spec.authors = ["Ismael Celis"] spec.email = ["ismaelct@gmail.com"] - 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} + spec.summary = %q{Adapters-based serializers with Hypermedia support} + 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} spec.homepage = "https://github.com/ismasan/oat" - spec.license = "MIT" - spec.files = `git ls-files`.split($/) - spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } - spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) + # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host' + # to allow pushing to a single host or delete this section to allow pushing to any host. + if spec.respond_to?(:metadata) + spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'" + else + raise "RubyGems 2.0 or newer is required to protect against " \ + "public gem pushes." + end + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do + `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } + end + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.add_development_dependency "bundler", "~> 1.3" - spec.add_development_dependency "rake" - spec.add_development_dependency "rspec", ">= 3.0" - spec.add_development_dependency "rspec-its" + spec.add_dependency "parametric", "~> 0.2" + + spec.add_development_dependency "bundler", "~> 1.16" + spec.add_development_dependency "rake", "~> 10.0" + spec.add_development_dependency "rspec", "~> 3.0" end diff --git a/spec/adapters/hal_spec.rb b/spec/adapters/hal_spec.rb deleted file mode 100644 index 94df8ac..0000000 --- a/spec/adapters/hal_spec.rb +++ /dev/null @@ -1,103 +0,0 @@ -require 'spec_helper' -require 'oat/adapters/hal' - -describe Oat::Adapters::HAL do - - include Fixtures - - let(:serializer) { serializer_class.new(user, {:name => 'some_controller'}, Oat::Adapters::HAL) } - let(:hash) { serializer.to_hash } - - describe '#to_hash' do - it 'produces a HAL-compliant hash' do - expect(hash).to include( - # properties - :id => user.id, - :name => user.name, - :age => user.age, - :controller_name => 'some_controller', - :message_from_above => nil, - # Meta property - :nation => 'zulu' - ) - - # links - expect(hash.fetch(:_links)).to include(:self => { :href => "http://foo.bar.com/#{user.id}" }) - - # HAL Spec says href is REQUIRED - expect(hash.fetch(:_links)).not_to include(:empty) - expect(hash.fetch(:_embedded)).to include(:manager, :friends) - - # embedded manager - expect(hash.fetch(:_embedded).fetch(:manager)).to include( - :id => manager.id, - :name => manager.name, - :age => manager.age, - :_links => { :self => { :href => "http://foo.bar.com/#{manager.id}" } } - ) - - # embedded friends - expect(hash.fetch(:_embedded).fetch(:friends).size).to be 1 - expect(hash.fetch(:_embedded).fetch(:friends).first).to include( - :id => friend.id, - :name => friend.name, - :age => friend.age, - :controller_name => 'some_controller', - :message_from_above => "Merged into parent's context", - :_links => { :self => { :href => "http://foo.bar.com/#{friend.id}" } } - ) - end - - context 'with a nil entity relationship' do - let(:manager) { nil } - - it 'produces a HAL-compliant hash' do - # properties - expect(hash).to include( - :id => user.id, - :name => user.name, - :age => user.age, - :controller_name => 'some_controller', - :message_from_above => nil - ) - - expect(hash.fetch(:_links)).to include(:self => { :href => "http://foo.bar.com/#{user.id}" }) - - # HAL Spec says href is REQUIRED - expect(hash.fetch(:_links)).not_to include(:empty) - expect(hash.fetch(:_embedded)).to include(:manager, :friends) - - expect(hash.fetch(:_embedded).fetch(:manager)).to be_nil - - # embedded friends - expect(hash.fetch(:_embedded).fetch(:friends).size).to be 1 - expect(hash.fetch(:_embedded).fetch(:friends).first).to include( - :id => friend.id, - :name => friend.name, - :age => friend.age, - :controller_name => 'some_controller', - :message_from_above => "Merged into parent's context", - :_links => { :self => { :href => "http://foo.bar.com/#{friend.id}" } } - ) - end - end - - let(:array_of_linked_objects_serializer) { - array_of_linked_objects_serializer_class.new(friendly_user, nil, Oat::Adapters::HAL) - } - let(:linked_objects_hash) { array_of_linked_objects_serializer.to_hash } - - context 'with an array of linked objects' do - it 'produces a HAL-compliant hash' do - expect(linked_objects_hash.fetch(:_links).fetch(:related).size).to be 3 - expect(linked_objects_hash.fetch(:_links)).to include( - :related => [ - {:type=>"friend", :href=>"http://foo.bar.com/1"}, - {:type=>"friend", :href=>"http://foo.bar.com/2"}, - {:type=>"friend", :href=>"http://foo.bar.com/3"} - ] - ) - end - end - end -end diff --git a/spec/adapters/json_api_spec.rb b/spec/adapters/json_api_spec.rb deleted file mode 100644 index 06a409a..0000000 --- a/spec/adapters/json_api_spec.rb +++ /dev/null @@ -1,359 +0,0 @@ -require 'spec_helper' -require 'oat/adapters/json_api' - -describe Oat::Adapters::JsonAPI do - - include Fixtures - - let(:serializer) { serializer_class.new(user, {:name => 'some_controller'}, Oat::Adapters::JsonAPI) } - let(:hash) { serializer.to_hash } - - describe '#to_hash' do - context 'top level' do - subject(:users){ hash.fetch(:users) } - its(:size) { should eq(1) } - - it 'contains the correct user properties' do - expect(users.first).to include( - :id => user.id, - :name => user.name, - :age => user.age, - :controller_name => 'some_controller', - :message_from_above => nil - ) - end - - it 'contains the correct user links' do - expect(users.first.fetch(:links)).to include( - :self => { - :href => "http://foo.bar.com/#{user.id}" - }, - # these links are added by embedding entities - :manager => manager.id, - :friends => [friend.id] - ) - end - end - - context 'meta' do - subject(:meta) { hash.fetch(:meta) } - - it 'contains meta properties' do - expect(meta[:nation]).to eq('zulu') - end - - context 'without meta' do - let(:serializer_class) { - Class.new(Oat::Serializer) do - schema do - type 'users' - end - end - } - - it 'does not contain meta information' do - expect(hash[:meta]).to be_nil - end - end - end - - context 'linked' do - context 'using #entities' do - subject(:linked_friends){ hash.fetch(:linked).fetch(:friends) } - - its(:size) { should eq(1) } - - it 'contains the correct properties' do - expect(linked_friends.first).to include( - :id => friend.id, - :name => friend.name, - :age => friend.age, - :controller_name => 'some_controller', - :message_from_above => "Merged into parent's context" - ) - end - - it 'contains the correct links' do - expect(linked_friends.first.fetch(:links)).to include( - :self => { - :href => "http://foo.bar.com/#{friend.id}" - } - ) - end - end - - context 'using #entity' do - subject(:linked_managers){ hash.fetch(:linked).fetch(:managers) } - - it "does not duplicate an entity that is associated with 2 objects" do - expect(linked_managers.size).to eq(1) - end - - it "contains the correct properties and links" do - expect(linked_managers.first).to include( - :id => manager.id, - :name => manager.name, - :age => manager.age, - :links => { :self => { :href => "http://foo.bar.com/#{manager.id}"} } - ) - end - end - - context 'with nested entities' do - let(:friend) { user_class.new('Joe', 33, 2, [other_friend]) } - let(:other_friend) { user_class.new('Jack', 28, 4, []) } - - subject(:linked_friends){ hash.fetch(:linked).fetch(:friends) } - its(:size) { should eq(2) } - - it 'has the correct entities' do - expect(linked_friends.map{ |friend| friend.fetch(:id) }).to include(2, 4) - end - end - end - - context 'object links' do - context "as string" do - let(:serializer_class) do - Class.new(Oat::Serializer) do - schema do - type 'users' - link :self, "45" - end - end - end - - it 'renders just the string' do - expect(hash.fetch(:users).first.fetch(:links)).to eq({ - :self => "45" - }) - end - end - - context 'as array' do - let(:serializer_class) do - Class.new(Oat::Serializer) do - schema do - type 'users' - link :self, ["45", "46", "47"] - end - end - end - - it 'renders the array' do - expect(hash.fetch(:users).first.fetch(:links)).to eq({ - :self => ["45", "46", "47"] - }) - end - end - - context 'as hash' do - context 'with single id' do - let(:serializer_class) do - Class.new(Oat::Serializer) do - schema do - type 'users' - link :self, :href => "http://foo.bar.com/#{item.id}", :id => item.id.to_s, :type => 'user' - end - end - end - - it 'renders all the keys' do - expect(hash.fetch(:users).first.fetch(:links)).to eq({ - :self => { - :href => "http://foo.bar.com/#{user.id}", - :id => user.id.to_s, - :type => 'user' - } - }) - end - end - - context 'with ids' do - let(:serializer_class) do - Class.new(Oat::Serializer) do - schema do - type 'users' - link :self, :href => "http://foo.bar.com/1,2,3", :ids => ["1", "2", "3"], :type => 'user' - end - end - end - - it 'renders all the keys' do - expect(hash.fetch(:users).first.fetch(:links)).to eq({ - :self => { - :href => "http://foo.bar.com/1,2,3", - :ids => ["1", "2", "3"], - :type => 'user' - } - }) - end - end - - context 'with id and ids' do - let(:serializer_class) do - Class.new(Oat::Serializer) do - schema do - type 'users' - link :self, :id => "45", :ids => ["1", "2", "3"] - end - end - end - - it "errs" do - expect{hash}.to raise_error(ArgumentError) - end - end - - context 'with invalid keys' do - let(:serializer_class) do - Class.new(Oat::Serializer) do - schema do - type 'users' - link :self, :not_a_valid_key => "value" - end - end - end - - it "errs" do - expect{hash}.to raise_error(ArgumentError) - end - end - end - end - - context 'with a nil entity relationship' do - let(:manager) { nil } - let(:users) { hash.fetch(:users) } - - it 'excludes the entity from user links' do - expect(users.first.fetch(:links)).not_to include(:manager) - end - - it 'excludes the entity from the linked hash' do - expect(hash.fetch(:linked)).not_to include(:managers) - end - end - - context 'with a nil entities relationship' do - let(:user) { user_class.new('Ismael', 35, 1, nil, manager) } - let(:users) { hash.fetch(:users) } - - it 'excludes the entity from user links' do - expect(users.first.fetch(:links)).not_to include(:friends) - end - - it 'excludes the entity from the linked hash' do - expect(hash.fetch(:linked)).not_to include(:friends) - end - end - - context 'when an empty entities relationship' do - let(:user) { user_class.new('Ismael', 35, 1, [], manager) } - let(:users) { hash.fetch(:users) } - - it 'excludes the entity from user links' do - expect(users.first.fetch(:links)).not_to include(:friends) - end - - it 'excludes the entity from the linked hash' do - expect(hash.fetch(:linked)).not_to include(:friends) - end - end - - context 'with an entity collection' do - let(:serializer_collection_class) do - USER_SERIALIZER = serializer_class unless defined?(USER_SERIALIZER) - Class.new(Oat::Serializer) do - schema do - type 'users' - collection :users, item, USER_SERIALIZER - end - end - end - - let(:collection_serializer){ - serializer_collection_class.new( - [user,friend], - {:name => "some_controller"}, - Oat::Adapters::JsonAPI - ) - } - let(:collection_hash) { collection_serializer.to_hash } - - context 'top level' do - subject(:users){ collection_hash.fetch(:users) } - its(:size) { should eq(2) } - - it 'contains the correct first user properties' do - expect(users[0]).to include( - :id => user.id, - :name => user.name, - :age => user.age, - :controller_name => 'some_controller', - :message_from_above => nil - ) - end - - it 'contains the correct second user properties' do - expect(users[1]).to include( - :id => friend.id, - :name => friend.name, - :age => friend.age, - :controller_name => 'some_controller', - :message_from_above => nil - ) - end - - it 'contains the correct user links' do - expect(users.first.fetch(:links)).to include( - :self => {:href => "http://foo.bar.com/#{user.id}"}, - # these links are added by embedding entities - :manager => manager.id, - :friends => [friend.id] - ) - end - - context 'sub entity' do - subject(:linked_managers){ collection_hash.fetch(:linked).fetch(:managers) } - - it "does not duplicate an entity that is associated with multiple objects" do - expect(linked_managers.size).to eq(1) - end - - it "contains the correct properties and links" do - expect(linked_managers.first).to include( - :id => manager.id, - :name => manager.name, - :age => manager.age, - :links => { :self => {:href =>"http://foo.bar.com/#{manager.id}"} } - ) - end - end - end - end - - context 'link_template' do - let(:serializer_class) do - Class.new(Oat::Serializer) do - schema do - type 'users' - link "user.managers", :href => "http://foo.bar.com/{user.id}/managers", :templated => true - link "user.friends", :href => "http://foo.bar.com/{user.id}/friends", :templated => true - end - end - end - - it 'renders them top level' do - expect(hash.fetch(:links)).to eq({ - "user.managers" => "http://foo.bar.com/{user.id}/managers", - "user.friends" => "http://foo.bar.com/{user.id}/friends" - }) - end - - it "doesn't render them as links on the resource" do - expect(hash.fetch(:users).first).to_not have_key(:links) - end - end - end -end diff --git a/spec/adapters/siren_spec.rb b/spec/adapters/siren_spec.rb deleted file mode 100644 index 3000aaf..0000000 --- a/spec/adapters/siren_spec.rb +++ /dev/null @@ -1,143 +0,0 @@ -require 'spec_helper' -require 'oat/adapters/siren' - -describe Oat::Adapters::Siren do - - include Fixtures - - let(:serializer) { serializer_class.new(user, {:name => 'some_controller'}, Oat::Adapters::Siren) } - let(:hash) { serializer.to_hash } - - describe '#to_hash' do - it 'produces a Siren-compliant hash' do - expect(hash.fetch(:class)).to match_array(['user']) - - expect(hash.fetch(:properties)).to include( - :id => user.id, - :name => user.name, - :age => user.age, - :controller_name => 'some_controller', - :message_from_above => nil, - # Meta property - :nation => 'zulu' - ) - - expect(hash.fetch(:links).size).to be 2 - expect(hash.fetch(:links)).to include( - { :rel => [:self], :href => "http://foo.bar.com/#{user.id}" }, - { :rel => [:empty], :href => nil } - ) - - expect(hash.fetch(:entities).size).to be 2 - - # embedded friends - embedded_friends = hash.fetch(:entities).select{ |o| o[:class].include? "user" } - expect(embedded_friends.size).to be 1 - expect(embedded_friends.first.fetch(:properties)).to include( - :id => friend.id, - :name => friend.name, - :age => friend.age, - :controller_name => 'some_controller', - :message_from_above => "Merged into parent's context" - ) - expect(embedded_friends.first.fetch(:links).first).to include( - :rel => [:self], - :href => "http://foo.bar.com/#{friend.id}" - ) - - # sub-entity rel is an array, so it may have multiple values - expect(embedded_friends.first.fetch(:rel)).to include(:friends) - expect(embedded_friends.first.fetch(:rel)).to include('http://example.org/rels/person') - - embedded_managers = hash.fetch(:entities).select{ |o| o[:class].include? "manager" } - expect(embedded_managers.size).to be 1 - expect(embedded_managers.first.fetch(:properties)).to include( - :id => manager.id, - :name => manager.name, - :age => manager.age - ) - expect(embedded_managers.first.fetch(:links).first).to include( - :rel => [:self], - :href => "http://foo.bar.com/#{manager.id}" - ) - expect(embedded_managers.first.fetch(:rel)).to include(:manager) - - # action close_account - actions = hash.fetch(:actions) - expect(actions.size).to eql(1) - expect(actions.first).to include( - :name => :close_account, - :href => "http://foo.bar.com/#{user.id}/close_account", - :class => ['danger', 'irreversible'], - :method => 'DELETE', - :type => 'application/json' - ) - - expect(actions.first.fetch(:fields)).to include( - :class => ['string'], - :name => :current_password, - :type => :password, - :title => 'enter password:' - ) - end - - context 'with a nil entity relationship' do - let(:manager) { nil } - - it 'produces a Siren-compliant hash' do - expect(hash.fetch(:class)).to match_array(['user']) - - expect(hash.fetch(:properties)).to include( - :id => user.id, - :name => user.name, - :age => user.age, - :controller_name => 'some_controller', - :message_from_above => nil - ) - - expect(hash.fetch(:links).size).to be 2 - expect(hash.fetch(:links)).to include( - { :rel => [:self], :href => "http://foo.bar.com/#{user.id}" }, - { :rel => [:empty], :href => nil } - ) - - expect(hash.fetch(:entities).size).to be 1 - - # embedded friends - embedded_friends = hash.fetch(:entities).select{ |o| o[:class].include? "user" } - expect(embedded_friends.size).to be 1 - expect(embedded_friends.first.fetch(:properties)).to include( - :id => friend.id, - :name => friend.name, - :age => friend.age, - :controller_name => 'some_controller', - :message_from_above => "Merged into parent's context" - ) - expect(embedded_friends.first.fetch(:links).first).to include( - :rel => [:self], - :href => "http://foo.bar.com/#{friend.id}" - ) - - embedded_managers = hash.fetch(:entities).select{ |o| o[:class].include? "manager" } - expect(embedded_managers.size).to be 0 - end - end - - context 'with multiple rels specified as an array for a single link' do - let(:serializer_class) do - Class.new(Oat::Serializer) do - schema do - type 'users' - link ['describedby', 'http://rels.foo.bar.com/type'], :href => "http://foo.bar.com/meta/user" - end - end - end - - it 'renders the rels as a Siren-compliant non-nested, flat array' do - expect(hash.fetch(:links)).to include( - {:rel=>["describedby", "http://rels.foo.bar.com/type"], :href=>"http://foo.bar.com/meta/user"} - ) - end - end - end -end diff --git a/spec/fixtures.rb b/spec/fixtures.rb deleted file mode 100644 index 1c6bbad..0000000 --- a/spec/fixtures.rb +++ /dev/null @@ -1,81 +0,0 @@ -module Fixtures - - def self.included(base) - base.let(:user_class) { Struct.new(:name, :age, :id, :friends, :manager) } - base.let(:friend) { user_class.new('Joe', 33, 2, []) } - base.let(:manager) { user_class.new('Jane', 29, 3, [friend]) } - base.let(:user) { user_class.new('Ismael', 35, 1, [friend], manager) } - base.let(:friendly_user) { user_class.new('Jeff', 33, 1, [friend, manager, user]) } - base.let(:serializer_class) do - Class.new(Oat::Serializer) do - klass = self - - schema do - type 'user' if respond_to?(:type) - link :self, :href => url_for(item.id) - link :empty, :href => nil - - meta :nation, 'zulu' - - property :id, item.id - map_properties :name, :age - properties do |attrs| - attrs.controller_name context[:name] - attrs.message_from_above context[:message] - end - - entities [:friends, 'http://example.org/rels/person'], item.friends, klass, :message => "Merged into parent's context" - - entity :manager, item.manager do |manager, s| - s.type 'manager' - s.link :self, :href => url_for(manager.id) - s.properties do |attrs| - attrs.id manager.id - attrs.name manager.name - attrs.age manager.age - end - - entities [:friends, 'http://example.org/rels/person'], item.friends, klass, :message => "Merged into parent's context" - end - - if adapter.respond_to?(:action) - action :close_account do |action| - action.href "http://foo.bar.com/#{item.id}/close_account" - action.klass 'danger' - action.klass 'irreversible' - action.method 'DELETE' - action.type 'application/json' - action.field :current_password do |field| - field.klass 'string' - field.type :password - field.title 'enter password:' - end - end - end - end - - def url_for(id) - "http://foo.bar.com/#{id}" - end - end - end - base.let(:array_of_linked_objects_serializer_class) do - Class.new(Oat::Serializer) do - schema do - type 'user' - - link :self, :href => url_for(item.id) - link :related, item.friends.map { |friend| - {:type => 'friend', :href => url_for(friend.id)} - }.sort_by { |link_obj| - link_obj[:href] - } - end - - def url_for(id) - "http://foo.bar.com/#{id}" - end - end - end - end -end diff --git a/spec/oat_spec.rb b/spec/oat_spec.rb new file mode 100644 index 0000000..fb968c6 --- /dev/null +++ b/spec/oat_spec.rb @@ -0,0 +1,410 @@ +RSpec.describe Oat do + let(:f1) { double('Friend1', name: 'F1') } + let(:f2) { double('Friend2', name: 'F2') } + let(:account) { double('Account', id: 111) } + let(:user) { + double("Item", + name: 'ismael', + age: '40', + friends: [f1, f2], + account: account, + ) + } + + it "has a version number" do + expect(Oat::VERSION).not_to be nil + end + + it "maps full HAL entities and sub-entities" do + user_serializer = Class.new(Oat::Serializer) do + schema do + link :account, from: :account_url, method: :get, title: "an account" + link :self, helper: :self_url + property :name, from: :name + property :age, type: :integer + entities :friends, from: :friends do |s| + s.property :name + end + end + + def self_url(user) + "#{context[:host]}/users/#{user.name}" + end + + present do + def account_url + "#{context[:host]}/accounts/#{item.account.id}" + end + end + end + + result = user_serializer.serialize(user, context: {host: 'https://api.com'}) + + expect(result[:name]).to eq 'ismael' + expect(result[:age]).to eq 40 + + result[:_links].tap do |links| + expect(links[:account][:href]).to eq 'https://api.com/accounts/111' + expect(links[:self][:href]).to eq 'https://api.com/users/ismael' + end + + result[:_embedded][:friends].tap do |friends| + expect(friends.size).to eq 2 + expect(friends.first[:name]).to eq 'F1' + end + end + + it "omits keys if :if option resolves to falsey" do + allow(user).to receive(:shows_name?).and_return false + allow(f1).to receive(:shows_name?).and_return false + allow(f2).to receive(:shows_name?).and_return true + + user_serializer = Class.new(Oat::Serializer) do + schema do + property :name, from: :name, if: :shows_name? + property :age, type: :integer + entity :account do |s| + s.property :id + end + entities :friends, from: :friends, if: :any? do |s| + s.property :name, if: :shows_name? + end + end + end + + result = user_serializer.serialize(user) + + expect(result.key?(:name)).to be false + expect(result[:age]).to eq 40 + result[:_embedded][:friends].tap do |friends| + expect(friends.first.key?(:name)).to be false + expect(friends.last[:name]).to eq 'F2' + end + + allow(user).to receive(:shows_name?).and_return true + + result = user_serializer.serialize(user) + + expect(result[:name]).to eq 'ismael' + + allow(user).to receive(:friends).and_return [] # #any? == false + result = user_serializer.serialize(user) + + expect(result[:_embedded].key?(:friends)).to be false + end + + it "maps sub-entities with named sub-serializer" do + friend_serializer = Class.new(Oat::Serializer) do + schema do + property :friend_name, from: :name + end + end + + user_serializer = Class.new(Oat::Serializer) do + schema do + property :name, from: :name + property :age, type: :integer + entities :friends, with: friend_serializer + end + end + + result = user_serializer.serialize(user) + + expect(result[:name]).to eq 'ismael' + expect(result[:age]).to eq 40 + + result[:_embedded][:friends].tap do |friends| + expect(friends.size).to eq 2 + expect(friends.first[:friend_name]).to eq 'F1' + end + end + + it "uses decorator methods and context object, if available" do + context = {title: "Mr/Mrs."} + + base_serializer = Class.new(Oat::Serializer) do + def with_title(item) + "#{context[:title]} #{item.name}" + end + end + + friend_serializer = Class.new(base_serializer) do + schema do + property :friend_name, helper: :with_title + end + end + + user_serializer = Class.new(base_serializer) do + schema do + property :name, helper: :with_title + property :age, type: :integer + entities :friends, with: friend_serializer + end + end + + result = user_serializer.serialize(user, context: context) + + expect(result[:name]).to eq 'Mr/Mrs. ismael' + expect(result[:age]).to eq 40 + + result[:_embedded][:friends].tap do |friends| + expect(friends.size).to eq 2 + expect(friends.first[:friend_name]).to eq 'Mr/Mrs. F1' + end + end + + it "uses custom class-level adapter" do + example_adapter = Proc.new do |data| + { + props: data[:properties] + } + end + + user_serializer = Class.new(Oat::Serializer) do + adapter example_adapter + schema do + property :name, from: :name + property :age, type: :integer + end + end + + result = user_serializer.serialize(user) + + expect(result[:props][:name]).to eq 'ismael' + expect(result[:props][:age]).to eq 40 + end + + it "uses custom run-time adapter" do + example_adapter = Proc.new do |data| + { + props: data[:properties] + } + end + + user_serializer = Class.new(Oat::Serializer) do + schema do + property :name, from: :name + property :age, type: :integer + end + end + + result = user_serializer.serialize(user, adapter: example_adapter) + + expect(result[:props][:name]).to eq 'ismael' + expect(result[:props][:age]).to eq 40 + end + + it "maps full entities and sub-entities with custom adapter" do + example_adapter = Proc.new do |data| + data + end + + user_serializer = Class.new(Oat::Serializer) do + adapter example_adapter + + schema do + property :name, from: :name + property :age, type: :integer + entity :account do |s| + s.property :account_id, from: :id + end + + entities :friends, from: :friends do |s| + s.property :name + end + end + end + + result = user_serializer.serialize(user) + + expect(result[:properties][:name]).to eq 'ismael' + expect(result[:properties][:age]).to eq 40 + + result[:entities][:friends].tap do |friends| + expect(friends.size).to eq 2 + expect(friends.first[:properties][:name]).to eq 'F1' + end + end + + context "with custom presenters" do + it "uses presenter blocks if available" do + user_serializer = Class.new(Oat::Serializer) do + schema do + property :name, from: :name + property :age, type: :integer + entities :friends, from: :friends do |s| + s.property :name + end + end + + present do + def name + "#{context[:title]} #{item.name}" + end + end + end + + result = user_serializer.serialize(user, context: {title: 'Mr.'}) + + expect(result[:name]).to eq 'Mr. ismael' + expect(result[:age]).to eq 40 + + result[:_embedded][:friends].tap do |friends| + expect(friends.size).to eq 2 + expect(friends.first[:name]).to eq 'F1' + end + end + + it "uses named presenters if available" do + user_presenter = Class.new(Oat::DefaultPresenter) do + def name + "Custom: #{context[:title]} #{item.name}" + end + end + + user_serializer = Class.new(Oat::Serializer) do + schema do + property :name, from: :name + property :age, type: :integer + end + + present user_presenter + end + + result = user_serializer.serialize(user, context: {title: 'Mr.'}) + + expect(result[:name]).to eq 'Custom: Mr. ismael' + end + + it "can switch presenters by item type" do + user_klass1 = Struct.new(:name) + user_klass2 = Struct.new(:name) + + user_serializer = Class.new(Oat::Serializer) do + schema do + property :name, from: :name + end + + present do + def name + "Custom: #{context[:title]} #{item.name}" + end + end + + present type: user_klass1 do + def name + "Special: #{item.name}" + end + end + end + + result = user_serializer.serialize(user_klass1.new("Joe"), context: {title: 'Mr.'}) + expect(result[:name]).to eq 'Special: Joe' + + result = user_serializer.serialize(user_klass2.new("Joan"), context: {title: 'Mrs.'}) + expect(result[:name]).to eq 'Custom: Mrs. Joan' + end + + it 'inherits presenters' do + user_klass = Struct.new(:name, :age) + + user_serializer = Class.new(Oat::Serializer) do + schema do + property :name + end + + present do + def name + "Mr. #{item.name}" + end + end + end + + child_serializer = Class.new(user_serializer) do + schema do + property :name + property :age + end + + # child defines own presenter too + present do + def age + "#{item.age} years old" + end + end + end + result = child_serializer.serialize(user_klass.new("Joe", 50)) + expect(result[:name]).to eq 'Mr. Joe' + expect(result[:age]).to eq '50 years old' + end + + it 'inherits schemas' + end + + context "generating example outputs" do + let(:user_serializer) do + Class.new(Oat::Serializer) do + schema do + link :account, helper: :account_url, method: :get, title: "an account", example: 'https://example.com/accounts/1' + property :name, from: :name, example: 'Joan' + property :age, type: :integer, example: 45 + entity :account do |s| + s.property :id, type: :integer, example: 123 + end + end + + def account_url(item) + "https://api.com/accounts/1" + end + end + end + + it "generates example" do + result = user_serializer.example + expect(result[:name]).to eq 'Joan' + expect(result[:age]).to eq 45 + expect(result[:_embedded][:account][:id]).to eq 123 + result[:_links][:account].tap do |link| + expect(link[:href]).to eq 'https://example.com/accounts/1' + expect(link[:method]).to eq :get + expect(link[:title]).to eq 'an account' + end + end + + it "generates example with custom run-time adapter" do + example_adapter = Proc.new do |data| + { + props: data[:properties] + } + end + + result = user_serializer.example(adapter: example_adapter) + expect(result[:props][:name]).to eq 'Joan' + expect(result[:props][:age]).to eq 45 + end + end + + it "raises useful exception if item doesn't respond to expected method" do + user = double("Item", + name: 'ismael', + ) + + user_serializer = Class.new(Oat::Serializer) do + schema do + property :name, from: :name + property :age, type: :integer + end + end + + expect { + user_serializer.serialize(user) + }.to raise_error Oat::NoMethodError + end + + # inheriting presenters + # inheriting schemas + # specific exception on schema errors + # conditional groups, to include/exclude groups of properties/links/entities depending on state or conditions + # impl. another adapter to test internal APIs. + # schemas with adapter? ie. generate JSON schema with example values and HAL structure. +end diff --git a/spec/serializer_spec.rb b/spec/serializer_spec.rb deleted file mode 100644 index 643ec87..0000000 --- a/spec/serializer_spec.rb +++ /dev/null @@ -1,116 +0,0 @@ -require 'spec_helper' - -describe Oat::Serializer do - - before do - @adapter_class = Class.new(Oat::Adapter) do - def attributes(&block) - data[:attributes].merge!(yield_props(&block)) - end - - def attribute(key, value) - data[:attributes][key] = value - end - - def link(rel, url) - data[:links][rel] = url - end - end - - @sc = Class.new(Oat::Serializer) do - - schema do - my_attribute 'Hello' - attribute :id, item.id - attributes do |attrs| - attrs.name item.name - attrs.age item.age - attrs.controller_name context[:name] - end - link :self, url_for(item.id) - end - - def url_for(id) - "http://foo.bar.com/#{id}" - end - - def my_attribute(value) - attribute :special, value - end - end - - @sc.adapter @adapter_class - end - - let(:user_class) do - Struct.new(:name, :age, :id, :friends) - end - - let(:user1) { user_class.new('Ismael', 35, 1, []) } - - it 'should have a version number' do - expect(Oat::VERSION).to_not be_nil - end - - describe "#context" do - it "is a hash by default" do - expect(@sc.new(user1).context).to be_a Hash - end - - it "can be set like an options hash" do - serializer = @sc.new(user1, :controller => double(:name => "Fancy")) - expect(serializer.context.fetch(:controller).name).to eq "Fancy" - end - end - - describe '#to_hash' do - it 'builds Hash from item and context with attributes as defined in adapter' do - serializer = @sc.new(user1, :name => 'some_controller') - expect(serializer.to_hash.fetch(:attributes)).to include( - :special => 'Hello', - :id => user1.id, - :name => user1.name, - :age => user1.age, - :controller_name => 'some_controller' - ) - - expect(serializer.to_hash.fetch(:links)).to include( - :self => "http://foo.bar.com/#{user1.id}" - ) - end - - context "when multiple schema blocks are specified across a class hierarchy" do - let(:child_serializer) { - Class.new(@sc) do - schema do - attribute :id_plus_x, "#{item.id}_x" - - attributes do |attrs| - attrs.inherited "true" - end - end - end - } - - it "produces the result of both schema blocks in order" do - serializer = child_serializer.new(user1, :name => "child_controller") - - expect(serializer.to_hash.fetch(:attributes)).to include( - :special => 'Hello', - :id => user1.id, - :id_plus_x => "#{user1.id}_x", - :inherited => "true" - ) - end - - it "does not affect the parent serializer" do - serializer = @sc.new(user1, :name => 'some_controller') - - attributes = serializer.to_hash.fetch(:attributes) - - expect(attributes).to_not have_key(:id_plus_x) - expect(attributes).to_not have_key(:inherited) - end - end - end -end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 4ccdc84..38ed075 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,4 +1,15 @@ -$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) -require 'rspec/its' -require 'oat' -require 'fixtures' +require "bundler/setup" +require "oat" +require 'byebug' + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = ".rspec_status" + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end