├── .gitignore ├── .rspec ├── .travis.yml ├── Appraisals ├── Gemfile ├── README.md ├── Rakefile ├── api_helper.gemspec ├── bin ├── console └── setup ├── examples └── includable_childs.rabl ├── gemfiles ├── grape_0.10.0.gemfile ├── grape_0.11.0.gemfile ├── rails_4.0.0.gemfile ├── rails_4.1.0.gemfile ├── rails_4.1.8.gemfile ├── rails_4.2.0.gemfile └── rails_5.0.0.gemfile ├── lib ├── api_helper.rb └── api_helper │ ├── fieldsettable.rb │ ├── filterable.rb │ ├── includable.rb │ ├── multigettable.rb │ ├── paginatable.rb │ ├── sortable.rb │ └── version.rb └── spec ├── api_helper ├── fieldsettable_spec.rb ├── filterable_spec.rb ├── grape │ ├── fieldsettable_spec.rb │ ├── filterable_spec.rb │ ├── includable_spec.rb │ ├── multigettable_spec.rb │ ├── paginatable_spec.rb │ └── sortable_spec.rb ├── includable_spec.rb ├── multigettable_spec.rb ├── paginatable_spec.rb ├── rails │ ├── fieldsettable_spec.rb │ ├── filterable_spec.rb │ ├── includable_spec.rb │ ├── multigettable_spec.rb │ ├── paginatable_spec.rb │ └── sortable_spec.rb └── sortable_spec.rb ├── api_helper_spec.rb ├── grape_helper.rb ├── rails_helper.rb ├── shared_context └── .keep ├── spec_helper.rb └── support └── active_record.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | /gemfiles/*.gemfile.lock 11 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.3.0 4 | gemfile: 5 | - gemfiles/rails_4.1.0.gemfile 6 | - gemfiles/rails_4.2.0.gemfile 7 | - gemfiles/rails_5.0.0.gemfile 8 | - gemfiles/grape_0.10.0.gemfile 9 | - gemfiles/grape_0.11.0.gemfile 10 | before_install: gem install bundler -v 1.10.2 11 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise 'rails-4.1.0' do 2 | gemspec 3 | gem 'rails', '4.1.0' 4 | gem 'rspec-rails' 5 | end 6 | 7 | appraise 'rails-4.2.0' do 8 | gemspec 9 | gem 'rails', '4.2.0' 10 | gem 'rspec-rails' 11 | end 12 | 13 | appraise 'rails-5.0.0' do 14 | gemspec 15 | gem 'rails', '5.0.0.beta2' 16 | gem 'rspec-rails' 17 | end 18 | 19 | appraise 'grape-0.10.0' do 20 | gemspec 21 | gem 'grape', '0.10.0' 22 | gem 'rack-test' 23 | end 24 | 25 | appraise 'grape-0.11.0' do 26 | gemspec 27 | gem 'grape', '0.11.0' 28 | gem 'rack-test' 29 | end 30 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in api_helper.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # APIHelper [![Gem Version](https://badge.fury.io/rb/api_helper.svg)](http://badge.fury.io/rb/api_helper) [![Build Status](https://travis-ci.org/Neson/api_helper.svg?branch=master)](https://travis-ci.org/Neson/api_helper) [![Coverage Status](https://coveralls.io/repos/Neson/api_helper/badge.svg?branch=master)](https://coveralls.io/r/Neson/api_helper?branch=master) [![Docs Status](https://inch-ci.org/github/Neson/api_helper.svg?branch=master)](https://inch-ci.org/github/Neson/api_helper) 2 | 3 | Helpers for creating standard RESTful API for Rails or Grape with Active Record. 4 | 5 | 6 | ## API Standards 7 | 8 |
9 | 10 |
Fieldsettable
11 |
Let clients choose the fields they wanted to be returned with the fields query parameter, making their API calls optimizable to gain efficiency and speed.
12 | 13 |
Includable
14 |
Clients can use the include query parameter to enable inclusion of related items - for instance, get the author's data along with a post.
15 | 16 |
Paginatable
17 |
Paginate the results of a resource collection, client can get a specific page with the page query parameter and set a custom page size with the "per_page" query parameter.
18 | 19 |
Sortable
20 |
Client can set custom sorting with the sort query parameter while getting a resource collection.
21 | 22 |
Filterable
23 |
Enables clients to filter through a resource collection with their fields with the filter query parameter.
24 | 25 |
Multigettable
26 |
Let Client execute operations on multiple resources with a single request.
27 | 28 |
29 | 30 | 31 | ## Installation 32 | 33 | Add this line to your application's Gemfile: 34 | 35 | ```ruby 36 | gem 'api_helper' 37 | ``` 38 | 39 | And then execute: 40 | 41 | $ bundle 42 | 43 | 44 | ## Usage 45 | 46 | ### Ruby on Rails (Action Pack) 47 | 48 | Include each helper concern you need in an `ActionController::Base`: 49 | 50 | ```ruby 51 | PostsController < ApplicationController 52 | include APIHelper::Filterable 53 | include APIHelper::Paginatable 54 | include APIHelper::Sortable 55 | 56 | # ... 57 | 58 | end 59 | ``` 60 | 61 | Further usage of each helper can be found in the [docs](http://www.rubydoc.info/github/zetavg/api_helper/master/APIHelper). 62 | 63 | ### Grape 64 | 65 | Set the helpers you need in an `Grape::API`: 66 | 67 | ```ruby 68 | class PostsAPI < Grape::API 69 | helpers APIHelper::Filterable 70 | helpers APIHelper::Paginatable 71 | helpers APIHelper::Sortable 72 | 73 | # ... 74 | 75 | end 76 | ``` 77 | 78 | Further usage of each helper can be found in the [docs](http://www.rubydoc.info/github/zetavg/api_helper/master/APIHelper). 79 | 80 | 81 | ## Development 82 | 83 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `appraisal rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 84 | 85 | 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). 86 | 87 | 88 | ## Contributing 89 | 90 | Bug reports and pull requests are welcome on GitHub at https://github.com/Neson/api_helper. 91 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /api_helper.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'api_helper/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "api_helper" 8 | spec.version = APIHelper::VERSION 9 | spec.authors = ["Neson"] 10 | spec.email = ["neson@dex.tw"] 11 | 12 | spec.summary = %q{Helpers for creating standard web API.} 13 | spec.description = %q{Helpers for creating standard web API for Rails or Grape with ActiveRecord.} 14 | spec.homepage = "https://github.com/Neson/APIHelper" 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 17 | spec.bindir = "exe" 18 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_development_dependency "bundler" 22 | spec.add_development_dependency "rake" 23 | spec.add_development_dependency "appraisal" 24 | spec.add_development_dependency "rspec" 25 | spec.add_development_dependency "simplecov" 26 | spec.add_development_dependency "coveralls" 27 | spec.add_development_dependency "byebug" 28 | spec.add_development_dependency "activerecord" 29 | spec.add_development_dependency "sqlite3" 30 | 31 | spec.add_dependency "activesupport", ">= 3" 32 | end 33 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "api_helper" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | appraisal install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /examples/includable_childs.rabl: -------------------------------------------------------------------------------- 1 | this = locals[:self_resource] 2 | 3 | unless inclusion_field(this).blank? 4 | inclusion_field(this).each_pair do |field_name, includable_field| 5 | # use this if fieldset is used 6 | # next if fieldset(this).present? && !fieldset(this).include?(field_name) 7 | 8 | # include that child 9 | if inclusion[this].include?(field_name) 10 | 11 | child field_name.to_sym, root: field_name.to_sym, object_root: false do 12 | template = (includable_field[:resource_name] || field_name).to_s.underscore.singularize 13 | extends template 14 | end 15 | 16 | # not to include that child 17 | else 18 | 19 | node field_name do |obj| 20 | if obj.try(includable_field[:id_field]).present? 21 | obj.try(includable_field[:id_field]) 22 | else 23 | foreign_key = obj.class.try("#{field_name}_foreign_key") 24 | obj.try(foreign_key) if foreign_key.present? 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /gemfiles/grape_0.10.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "grape", "0.10.0" 6 | gem "rack-test" 7 | 8 | gemspec :path => "../" 9 | -------------------------------------------------------------------------------- /gemfiles/grape_0.11.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "grape", "0.11.0" 6 | gem "rack-test" 7 | 8 | gemspec :path => "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_4.0.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "4.0.0" 6 | gem "rspec-rails" 7 | 8 | gemspec :path => "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_4.1.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "4.1.0" 6 | gem "rspec-rails" 7 | 8 | gemspec :path => "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_4.1.8.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "4.2.0" 6 | gem "rspec-rails" 7 | 8 | gemspec :path => "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_4.2.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "4.2.0" 6 | gem "rspec-rails" 7 | 8 | gemspec :path => "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_5.0.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "5.0.0.beta2" 6 | gem "rspec-rails" 7 | 8 | gemspec :path => "../" 9 | -------------------------------------------------------------------------------- /lib/api_helper.rb: -------------------------------------------------------------------------------- 1 | require "api_helper/version" 2 | require "api_helper/fieldsettable" 3 | require "api_helper/includable" 4 | require "api_helper/paginatable" 5 | require "api_helper/sortable" 6 | require "api_helper/filterable" 7 | require "api_helper/multigettable" 8 | 9 | module APIHelper 10 | end 11 | -------------------------------------------------------------------------------- /lib/api_helper/fieldsettable.rb: -------------------------------------------------------------------------------- 1 | require 'active_support' 2 | require 'active_support/core_ext/object/blank' 3 | 4 | # = Fieldsettable 5 | # 6 | # By making an API fieldsettable, you enables the ability for API clients to 7 | # choose the returned fields of resources with URL query parameters. This is 8 | # really useful for optimizing requests, making API calls more efficient and 9 | # fast. 10 | # 11 | # This design made references to the rules of Sparse Fieldsets in 12 | # JSON API: 13 | # http://jsonapi.org/format/#fetching-sparse-fieldsets 14 | # 15 | # A client can request to get only specific fields in the response by using 16 | # the +fields+ parameter, which is expected to be a comma-separated (",") list 17 | # that refers to the name(s) of the fields to be returned. 18 | # 19 | # GET /users?fields=id,name,avatar_url 20 | # 21 | # This functionality may also support requests passing in multiple fieldsets 22 | # for several resource at a time (e.g. an included related resource in an field 23 | # of another resource) with fields[object_type] parameters. 24 | # 25 | # GET /posts?fields[posts]=id,title,author&fields[user]=id,name,avatar_url 26 | # 27 | # Note: +author+ of a +post+ is a +user+. 28 | # 29 | # The +fields+ and fields[object_type] parameters can not be mixed. 30 | # If the latter format is used, then it must be used for the main resource as 31 | # well. 32 | # 33 | # == Usage 34 | # 35 | # Include this +Concern+ in your Action Controller: 36 | # 37 | # SamplesController < ApplicationController 38 | # include APIHelper::Fieldsettable 39 | # end 40 | # 41 | # or in your Grape API class: 42 | # 43 | # class SampleAPI < Grape::API 44 | # helpers APIHelper::Fieldsettable 45 | # end 46 | # 47 | # Then set fieldset with +fieldset_for+ for each resource in the controller: 48 | # 49 | # def index 50 | # fieldset_for :post, default: true, default_fields: [:id, :title, :author] 51 | # fieldset_for :user, permitted_fields: [:id, :name, :posts, :avatar_url], 52 | # defaults_to_permitted_fields: true 53 | # # ... 54 | # end 55 | # 56 | # or in the Grape method if you're using Grape: 57 | # 58 | # resources :posts do 59 | # get do 60 | # fieldset_for :post, default: true, default_fields: [:id, :title, :author] 61 | # fieldset_for :user, permitted_fields: [:id, :name, :posts, :avatar_url], 62 | # defaults_to_permitted_fields: true 63 | # # ... 64 | # end 65 | # end 66 | # 67 | # The +fieldset_for+ method used above parses the +fields+ and/or 68 | # fields[resource_name] parameters, and save the results into 69 | # +@fieldset+ instance variable for further usage. 70 | # 71 | # After that line, you can use the +fieldset+ helper method to get the fieldset 72 | # information. Actual examples are: 73 | # 74 | # With GET /posts?fields=title,author: 75 | # 76 | # fieldset #=> { post: [:title, :author], user: [:id, :name, :posts, :avatar_url] } 77 | # 78 | # With GET /posts?fields[post]=title,author&fields[user]=name: 79 | # 80 | # fieldset #=> { post: [:title, :author], user: [:name] } 81 | # fieldset(:post) #=> [:title, :author] 82 | # fieldset(:post, :title) #=> true 83 | # fieldset(:user, :avatar_url) #=> false 84 | # 85 | # You can make use of these information while dealing with requests in the 86 | # controller, for example: 87 | # 88 | # Post.select(fieldset(:post)).find(params[:id]) 89 | # 90 | # And return only specified fields in the view, for instance, Jbuilder: 91 | # 92 | # json.(@post, *fieldset(:post)) 93 | # json.author do 94 | # json.(@author, *fieldset(:user)) 95 | # end 96 | # 97 | # or RABL: 98 | # 99 | # # post.rabl 100 | # 101 | # object @post 102 | # attributes(*fieldset[:post]) 103 | # child :author do 104 | # extends 'user' 105 | # end 106 | # 107 | # # user.rabl 108 | # 109 | # object @user 110 | # attributes(*fieldset[:user]) 111 | # 112 | # You can also set properties of fieldset with the +set_fieldset+ helper method 113 | # in the views if you're using a same view across multiple controllers, for 114 | # decreasing code duplication or increasing security. Below is an example with 115 | # RABL: 116 | # 117 | # object @user 118 | # 119 | # # this ensures that the +fieldset+ instance variable is least setted with 120 | # # the default fields, and double filters +permitted_fields+ at view layer - 121 | # # in case of any things going wrong in the controller 122 | # set_fieldset :user, default_fields: [:id, :name, :avatar_url], 123 | # permitted_fields: [:id, :name, :avatar_url, :posts] 124 | # 125 | # # determine the fields to show on the fly 126 | # attributes(*fieldset[:user]) 127 | # 128 | module APIHelper::Fieldsettable 129 | extend ActiveSupport::Concern 130 | 131 | # Gets the fields parameters, organize them into a +@fieldset+ hash for model to select certain. 132 | # fields and/or templates to render specified fieldset. Following the URL rules of JSON API: 133 | # http://jsonapi.org/format/#fetching-sparse-fieldsets 134 | # 135 | # Params: 136 | # 137 | # +resource+:: 138 | # +Symbol+ name of resource to receive the fieldset 139 | # 140 | # +default+:: 141 | # +Boolean+ should this resource take the parameter from +fields+ while no 142 | # resourse name is specified? 143 | # 144 | # +permitted_fields+:: 145 | # +Array+ of +Symbol+s list of accessible fields used to filter out unpermitted fields, 146 | # defaults to permit all 147 | # 148 | # +default_fields+:: 149 | # +Array+ of +Symbol+s list of fields to show by default 150 | # 151 | # +defaults_to_permitted_fields+:: 152 | # +Boolean+ if set to true, @fieldset will be set to all permitted_fields 153 | # when the current resource's fieldset isn't specified 154 | # 155 | # Example Result: 156 | # 157 | # fieldset_for :user, root: true 158 | # fieldset_for :group 159 | # 160 | # # @fieldset => { 161 | # # :user => [:id, :name, :email, :groups], 162 | # # :group => [:id, :name] 163 | # # } 164 | # 165 | def fieldset_for(resource, default: false, 166 | permitted_fields: [], 167 | defaults_to_permitted_fields: false, 168 | default_fields: []) 169 | @fieldset ||= ActiveSupport::HashWithIndifferentAccess.new 170 | 171 | # put the fields in place 172 | if params[:fields].is_a?(Hash) || 173 | defined?(Rails) && Rails.version.to_i >= 5 && params[:fields].is_a?(ActionController::Parameters) 174 | # get the specific resource fields from fields hash 175 | @fieldset[resource] = params[:fields][resource] || params[:fields][resource] 176 | elsif default 177 | # or get the fields string directly if this resource is th default one 178 | @fieldset[resource] = params[:fields] 179 | end 180 | 181 | # splits the string into array 182 | if @fieldset[resource].present? 183 | @fieldset[resource] = @fieldset[resource].split(',').map(&:to_s) 184 | else 185 | @fieldset[resource] = default_fields.map(&:to_s) 186 | end 187 | 188 | if permitted_fields.present? 189 | permitted_fields = permitted_fields.map(&:to_s) 190 | 191 | # filter out unpermitted fields by intersecting them 192 | @fieldset[resource] &= permitted_fields if @fieldset[resource].present? 193 | 194 | # set default fields to permitted_fields if needed 195 | @fieldset[resource] = permitted_fields if @fieldset[resource].blank? && 196 | defaults_to_permitted_fields 197 | end 198 | end 199 | 200 | # Getter for the fieldset data 201 | # 202 | # This method will act as a traditional getter of the fieldset data and 203 | # returns a hash containing fields for each resource if no parameter is 204 | # provided. 205 | # 206 | # fieldset # => { 'user' => ['name'], 'post' => ['title', 'author'] } 207 | # 208 | # If one parameter - a specific resourse name is passed in, it will return 209 | # a fields array of that specific resourse. 210 | # 211 | # fieldset(:post) # => ['title', 'author'] 212 | # 213 | # And if one more parameter - a field name, is passed in, it will return a 214 | # boolen, determining if that field should exist in that resource. 215 | # 216 | # fieldset(:post, :title) # => true 217 | # 218 | def fieldset(resource = nil, field = nil) 219 | # act as a traditional getter if no parameters specified 220 | if resource.blank? 221 | @fieldset ||= ActiveSupport::HashWithIndifferentAccess.new 222 | 223 | # returns the fieldset array if an specific resource is passed in 224 | elsif field.blank? 225 | fieldset[resource] || [] 226 | 227 | # determine if a field is inculded in a specific fieldset 228 | else 229 | field = field.to_s 230 | fieldset(resource).is_a?(Array) && fieldset(resource).include?(field) 231 | end 232 | end 233 | 234 | # View Helper to set the default and permitted fields 235 | # 236 | # This is useful while using an resource view shared by multiple controllers, 237 | # it will ensure the +@fieldset+ instance variable presents, and can also set 238 | # the default fields of a model for convenience, or the whitelisted permitted 239 | # fields for security. 240 | def set_fieldset(resource, default_fields: [], permitted_fields: []) 241 | @fieldset ||= ActiveSupport::HashWithIndifferentAccess.new 242 | @fieldset[resource] = default_fields.map(&:to_s) if @fieldset[resource].blank? 243 | @fieldset[resource] &= permitted_fields.map(&:to_s) if permitted_fields.present? 244 | end 245 | 246 | # Returns the description of the 'fields' URL parameter 247 | def self.fields_param_desc(example: nil) 248 | if example.present? 249 | "Choose the fields to be returned. Example value: '#{example}'" 250 | else 251 | "Choose the fields to be returned." 252 | end 253 | end 254 | 255 | included do 256 | if defined? helper_method 257 | helper_method :fieldset, :set_fieldset 258 | end 259 | end 260 | end 261 | -------------------------------------------------------------------------------- /lib/api_helper/filterable.rb: -------------------------------------------------------------------------------- 1 | require 'active_support' 2 | require 'active_support/core_ext/object/blank' 3 | 4 | # = Filterable 5 | # 6 | # A filterable resource API supports requests to filter resources in collection 7 | # by their fields, using the +filter+ query parameter. 8 | # 9 | # For example, the following is a request for all products that has a 10 | # particular color: 11 | # 12 | # GET /products?filter[color]=red 13 | # 14 | # With this approach, multiple filters can be applied to a single request: 15 | # 16 | # GET /products?filter[color]=red&filter[status]=in-stock 17 | # 18 | # Multiple filters are applied with the AND condition. 19 | # 20 | # A list separated by commas (",") can be used to filter by field matching one 21 | # of the values: 22 | # 23 | # GET /products?filter[color]=red,blue,yellow 24 | # 25 | # A few functions: +not+, +greater_then+, +less_then+, +greater_then_or_equal+, 26 | # +less_then_or_equal+, +between+, +like+, +contains+, +null+ and +blank+ can 27 | # be used to filter the data, for example: 28 | # 29 | # GET /products?filter[color]=not(red) 30 | # GET /products?filter[price]=greater_then(1000) 31 | # GET /products?filter[price]=less_then_or_equal(2000) 32 | # GET /products?filter[price]=between(1000,2000) 33 | # GET /products?filter[name]=like(%lovely%) 34 | # GET /products?filter[name]=contains(%lovely%) 35 | # GET /products?filter[provider]=null() 36 | # GET /products?filter[provider]=blank() 37 | # 38 | # == Usage 39 | # 40 | # Include this +Concern+ in your Action Controller: 41 | # 42 | # SamplesController < ApplicationController 43 | # include APIHelper::Filterable 44 | # end 45 | # 46 | # or in your Grape API class: 47 | # 48 | # class SampleAPI < Grape::API 49 | # helpers APIHelper::Filterable 50 | # end 51 | # 52 | # then use the +filter+ method in the controller like this: 53 | # 54 | # @products = filter(Post, filterable_fields: [:name, :price, :color]) 55 | # 56 | # The +filter+ method will return a scoped model collection, based 57 | # directly from the requested URL parameters. 58 | # 59 | module APIHelper::Filterable 60 | extend ActiveSupport::Concern 61 | 62 | # Filter resources of a collection from the request parameter 63 | # 64 | # Params: 65 | # 66 | # +resource+:: 67 | # +ActiveRecord::Relation+ resource collection 68 | # to filter data from 69 | # 70 | # +filterable_fields+:: 71 | # +Array+ of +Symbol+s fields that are allowed to be filtered, defaults 72 | # to all 73 | # 74 | def filter(resource, filterable_fields: [], ignore_unknown_fields: true) 75 | # parse the request parameter 76 | if params[:filter].is_a?(Hash) || 77 | defined?(Rails) && Rails.version.to_i >= 5 && params[:filter].is_a?(ActionController::Parameters) 78 | @filter = params[:filter] 79 | filterable_fields = filterable_fields.map(&:to_s) 80 | 81 | # deal with each condition 82 | @filter.each_pair do |field, condition| 83 | # bypass fields that aren't be abled to filter with 84 | next if filterable_fields.present? && !filterable_fields.include?(field) 85 | 86 | # escape string to prevent SQL injection 87 | field = resource.connection.quote_string(field) 88 | 89 | next if ignore_unknown_fields && resource.columns_hash[field].blank? 90 | field_type = resource.columns_hash[field] && resource.columns_hash[field].type || :unknown 91 | 92 | # if a function is used 93 | if func = condition.match(/(?[^\(\)]+)\((?.*)\)/) 94 | 95 | db_column_name = begin 96 | raise if ActiveRecord::Base.configurations[Rails.env]['adapter'] != 'mysql2' 97 | "`#{resource.table_name}`.`#{field}`" 98 | rescue 99 | "\"#{resource.table_name}\".\"#{field}\"" 100 | end 101 | 102 | case func[:function] 103 | when 'not' 104 | values = func[:param].split(',') 105 | values.map!(&:to_bool) if field_type == :boolean 106 | resource = resource.where.not(field => values) 107 | 108 | when 'greater_then' 109 | resource = resource 110 | .where("#{db_column_name} > ?", 111 | func[:param]) 112 | 113 | when 'less_then' 114 | resource = resource 115 | .where("#{db_column_name} < ?", 116 | func[:param]) 117 | 118 | when 'greater_then_or_equal' 119 | resource = resource 120 | .where("#{db_column_name} >= ?", 121 | func[:param]) 122 | 123 | when 'less_then_or_equal' 124 | resource = resource 125 | .where("#{db_column_name} <= ?", 126 | func[:param]) 127 | 128 | when 'between' 129 | param = func[:param].split(',') 130 | resource = resource 131 | .where("#{db_column_name} BETWEEN ? AND ?", 132 | param.first, param.last) 133 | 134 | when 'like' 135 | resource = resource 136 | .where("#{db_column_name} LIKE ?", 137 | func[:param]) 138 | 139 | when 'contains' 140 | resource = resource 141 | .where("#{db_column_name} LIKE ?", 142 | "%#{func[:param]}%") 143 | 144 | when 'null' 145 | resource = resource.where(field => nil) 146 | 147 | when 'blank' 148 | resource = resource.where(field => [nil, '']) 149 | end 150 | 151 | # if not function 152 | else 153 | values = condition.split(',') 154 | values.map!(&:to_bool) if field_type == :boolean 155 | resource = resource.where(field => values) 156 | end 157 | end 158 | end 159 | 160 | return resource 161 | end 162 | 163 | # Returns a description of the 'fields' URL parameter 164 | def self.filter_param_desc(for_field: nil) 165 | if for_field.present? 166 | "Filter data base on the '#{for_field}' field." 167 | else 168 | "Filter the data." 169 | end 170 | end 171 | end 172 | 173 | class String 174 | def to_bool 175 | self == 'true' 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /lib/api_helper/includable.rb: -------------------------------------------------------------------------------- 1 | require 'active_support' 2 | 3 | # = Includable 4 | # 5 | # Inclusion lets your API returns not only the data of the primary resource, 6 | # but also resources that have relation to it. Includable APIs will also 7 | # support customising the resources included using the +include+ parameter. 8 | # 9 | # This design made references to the rules of Inclusion of Related 10 | # Resources in JSON API: 11 | # http://jsonapi.org/format/#fetching-includes 12 | # 13 | # For instance, articles can be requested with their comments along: 14 | # 15 | # GET /articles?include=comments 16 | # 17 | # The server will respond 18 | # 19 | # [ 20 | # { 21 | # "id": 1, 22 | # "title": "First Post", 23 | # "content": "...", 24 | # "comments": [ 25 | # { 26 | # "id": 1, 27 | # "content": "..." 28 | # }, 29 | # { 30 | # "id": 3, 31 | # "content": "..." 32 | # }, 33 | # { 34 | # "id": 6, 35 | # "content": "..." 36 | # } 37 | # ] 38 | # }, 39 | # { 40 | # "id": 2, 41 | # "title": "Second Post", 42 | # "content": "...", 43 | # "comments": [ 44 | # { 45 | # "id": 2, 46 | # "content": "..." 47 | # }, 48 | # { 49 | # "id": 4, 50 | # "content": "..." 51 | # }, 52 | # { 53 | # "id": 5, 54 | # "content": "..." 55 | # } 56 | # ] 57 | # } 58 | # ] 59 | # 60 | # instead of just the ids of each comment 61 | # 62 | # [ 63 | # { 64 | # "id": 1, 65 | # "title": "First Post", 66 | # "content": "...", 67 | # "comments": [1, 3, 6] 68 | # }, 69 | # { 70 | # "id": 2, 71 | # "title": "Second Post", 72 | # "content": "...", 73 | # "comments": [2, 4, 5] 74 | # } 75 | # ] 76 | # 77 | # Multiple related resources can be stated in a comma-separated list, 78 | # like this: 79 | # 80 | # GET /articles/12?include=author,comments 81 | # 82 | # == Usage 83 | # 84 | # Include this +Concern+ in your Action Controller: 85 | # 86 | # SamplesController < ApplicationController 87 | # include APIHelper::Includable 88 | # end 89 | # 90 | # or in your Grape API class: 91 | # 92 | # class SampleAPI < Grape::API 93 | # helpers APIHelper::Includable 94 | # end 95 | # 96 | # Then setup inclusion with +inclusion_for+ in the controller: 97 | # 98 | # def index 99 | # inclusion_for :post, default: true 100 | # # ... 101 | # end 102 | # 103 | # or in the Grape method if you're using it: 104 | # 105 | # resources :posts do 106 | # get do 107 | # inclusion_for :post, default: true 108 | # # ... 109 | # end 110 | # end 111 | # 112 | # This helper parses the +include+ and/or include[resource_name] 113 | # parameters and saves the results into +@inclusion+ for further usage. 114 | # 115 | # +Includable+ integrates with +Fieldsettable+ if used together, by: 116 | # 117 | # * Sliceing the included fields that dosen't appears in the fieldset - since 118 | # the included resoure(s) are actually fields under the primary resorce, 119 | # fieldset will be in charged to determine the fields to show. Thus, fields 120 | # will be totally ignored if they aren't appeared in the fieldset, regardless 121 | # if they are included or not. 122 | # 123 | # So notice that +inclusion_for+ should be set after +fieldset_for+ if both are 124 | # used! 125 | # 126 | # After that +inclusion_for ...+ line, you can use the +inclusion+ helper 127 | # method to get the inclusion data of each request, and do something like this 128 | # in your controller: 129 | # 130 | # @posts = Post.includes(inclusion(:post)) 131 | # 132 | # The +inclusion+ helper method will return data depending on the parameters 133 | # passed in, as the following example: 134 | # 135 | # inclusion # => { 'post' => ['author'] } 136 | # inclusion(:post) # => ['author'] 137 | # inclusion(:post, :author) # => true 138 | # 139 | # And don't forget to set your API views or serializers with the help of 140 | # +inclusion+ to provide dynamic included resources! 141 | # 142 | # === API View with RABL 143 | # 144 | # If you're using RABL as the API view, it can be setup like this: 145 | # 146 | # object @post 147 | # 148 | # # set the includable and default inclusion fields of the view 149 | # set_inclusion :post, default_includes: [:author] 150 | # 151 | # # set the details for all includable fields 152 | # set_inclusion_field :post, :author, :author_id 153 | # set_inclusion_field :post, :comments, :comment_ids 154 | # 155 | # # extends the partial to show included fields 156 | # extends('extensions/includable_childs', locals: { self_resource: :post }) 157 | # 158 | # -- 159 | # TODO: provide an example of includable_childs.rabl 160 | # ++ 161 | # 162 | module APIHelper::Includable 163 | extend ActiveSupport::Concern 164 | 165 | # Gets the include parameters, organize them into a +@inclusion+ hash 166 | # 167 | # Params: 168 | # 169 | # +resource+:: 170 | # +Symbol+ name of resource to receive the inclusion 171 | # 172 | # +default+:: 173 | # +Boolean+ should this resource take the parameter from +include+ while no 174 | # resourse name is specified? 175 | # 176 | # +permitted_includes+:: 177 | # +Array+ of +Symbol+s list of includable fields, permitting all by default 178 | # 179 | # +default_includes+:: 180 | # +Array+ of +Symbol+s list of fields to be included by default 181 | # 182 | # +defaults_to_permitted_includes+:: 183 | # +Boolean+ if set to true, +@inclusion+ will be set to all 184 | # permitted_includes when the current resource's included fields 185 | # isn't specified 186 | # 187 | def inclusion_for(resource, default: false, 188 | permitted_includes: [], 189 | defaults_to_permitted_includes: false, 190 | default_includes: []) 191 | @inclusion ||= ActiveSupport::HashWithIndifferentAccess.new 192 | @inclusion_specified ||= ActiveSupport::HashWithIndifferentAccess.new 193 | 194 | # put the fields in place 195 | if params[:include].is_a?(Hash) || 196 | defined?(Rails) && Rails.version.to_i >= 5 && params[:include].is_a?(ActionController::Parameters) 197 | # get the specific resource inclusion fields from the "include" hash 198 | @inclusion[resource] = params[:include][resource] 199 | @inclusion_specified[resource] = true if params[:include][resource].present? 200 | elsif default 201 | # or get the "include" string directly if this resource is th default one 202 | @inclusion[resource] = params[:include] 203 | @inclusion_specified[resource] = true if params[:include].present? 204 | end 205 | 206 | # splits the string into array 207 | if @inclusion[resource].present? 208 | @inclusion[resource] = @inclusion[resource].split(',').map(&:to_s) 209 | elsif !@inclusion_specified[resource] 210 | @inclusion[resource] = default_includes.map(&:to_s) 211 | end 212 | 213 | if permitted_includes.present? 214 | permitted_includes = permitted_includes.map(&:to_s) 215 | 216 | # filter out unpermitted includes by intersecting them 217 | @inclusion[resource] &= permitted_includes if @inclusion[resource].present? 218 | 219 | # set default inclusion to permitted_includes if needed 220 | @inclusion[resource] = permitted_includes if @inclusion[resource].blank? && 221 | defaults_to_permitted_includes && 222 | !@inclusion_specified[resource] 223 | end 224 | 225 | if @fieldset.is_a?(Hash) && @fieldset[resource].present? 226 | @inclusion[resource] &= @fieldset[resource] 227 | end 228 | end 229 | 230 | # Getter for the inclusion data 231 | # 232 | # This method will act as a traditional getter of the inclusion data and 233 | # returns a hash containing fields for each resource if no parameter is 234 | # provided. 235 | # 236 | # inclusion # => { 'post' => ['author', 'comments'] } 237 | # 238 | # If one parameter - a specific resourse name is passed in, it will return an 239 | # array of relation names that should be included for that specific resourse. 240 | # 241 | # inclusion(:post) # => ['author', 'comments'] 242 | # 243 | # And if one more parameter - a field name, is passed in, it will return a 244 | # boolen, determining if that relation should be included in the response. 245 | # 246 | # inclusion(:post, :author) # => true 247 | # 248 | def inclusion(resource = nil, field = nil) 249 | # act as a traditional getter if no parameters specified 250 | if resource.blank? 251 | @inclusion ||= ActiveSupport::HashWithIndifferentAccess.new 252 | 253 | # returns the inclusion array if an specific resource is passed in 254 | elsif field.blank? 255 | inclusion[resource] || [] 256 | 257 | # determine if a field is inculded 258 | else 259 | field = field.to_s 260 | inclusion(resource).is_a?(Array) && inclusion(resource).include?(field) 261 | end 262 | end 263 | 264 | # View Helper to set the inclusion 265 | # 266 | # This is useful while using an resource view shared by multiple controllers, 267 | # this will ensure the +@inclusion+ instance variable presents, and can also 268 | # set the default included fields of a model for convenience, or the fields 269 | # that are permitted to be included for security. 270 | def set_inclusion(resource, default_includes: [], permitted_includes: []) 271 | @inclusion ||= ActiveSupport::HashWithIndifferentAccess.new 272 | @inclusion_field ||= ActiveSupport::HashWithIndifferentAccess.new 273 | @inclusion_specified ||= ActiveSupport::HashWithIndifferentAccess.new 274 | @inclusion[resource] = default_includes.map(&:to_s) if @inclusion[resource].blank? && 275 | !@inclusion_specified[resource] 276 | @inclusion[resource] &= permitted_includes.map(&:to_s) if permitted_includes.present? 277 | 278 | if @fieldset.is_a?(Hash) && @fieldset[resource].present? 279 | @inclusion[resource] &= @fieldset[resource] 280 | end 281 | end 282 | 283 | # View Helper to set the inclusion details 284 | # 285 | # Params: 286 | # 287 | # +resource+:: 288 | # +Symbol+ name of the resource to receive the inclusion field data 289 | # 290 | # +field+:: 291 | # +Symbol+ the field name of the relatiion that can be included 292 | # 293 | # +id_field+:: 294 | # +Symbol+ the field to use (normally suffixed with "_id") if the object 295 | # isn't included 296 | # 297 | # +resource_name+:: 298 | # +Symbol+ the name of the child resource, can be used to determine which 299 | # view template should be extended for rendering that child node and also 300 | # can shown in the response metadata as well 301 | # 302 | # +resources_url+:: 303 | # +String+ the resources URL of the child resource, can be used to be shown 304 | # in the metadata for the clients' convenience to learn ablou the API 305 | # 306 | def set_inclusion_field(resource, field, id_field, resource_name: nil, 307 | resources_url: nil) 308 | @inclusion_field ||= ActiveSupport::HashWithIndifferentAccess.new 309 | @inclusion_field[resource] ||= ActiveSupport::HashWithIndifferentAccess.new 310 | @inclusion_field[resource][field] = { 311 | field: field, 312 | id_field: id_field, 313 | resource_name: resource_name, 314 | resources_url: resources_url 315 | } 316 | end 317 | 318 | # Getter for the data of includable fields 319 | # 320 | # Params: 321 | # 322 | # +resource+:: 323 | # +Symbol+ the resource name of inclusion data to retrieve 324 | # 325 | def inclusion_field(resource = nil) 326 | # act as a traditional getter if no parameters specified 327 | if resource.blank? 328 | @inclusion_field ||= ActiveSupport::HashWithIndifferentAccess.new 329 | 330 | # returns the inclusion array if an specific resource is passed in 331 | else 332 | inclusion_field[resource] || {} 333 | end 334 | end 335 | 336 | # Returns the description of the 'include' URL parameter 337 | def self.include_param_desc(example: nil, default: nil) 338 | if default.present? 339 | desc = "Returning compound documents that include specific associated objects, defaults to '#{default}'." 340 | else 341 | desc = "Returning compound documents that include specific associated objects." 342 | end 343 | 344 | if example.present? 345 | "#{desc} Example value: '#{example}'" 346 | else 347 | desc 348 | end 349 | end 350 | 351 | included do 352 | if defined? helper_method 353 | helper_method :inclusion, :set_inclusion, :set_inclusion_field, :inclusion_field 354 | end 355 | end 356 | end 357 | -------------------------------------------------------------------------------- /lib/api_helper/multigettable.rb: -------------------------------------------------------------------------------- 1 | require 'active_support' 2 | 3 | # = Multigettable 4 | # 5 | # A normal RESTful API can let clients get one specified resource at a time: 6 | # 7 | # GET /posts/3 8 | # 9 | # If it's declared to be multigettable, then clients can get multiple 10 | # specified resource like this: 11 | # 12 | # GET /posts/3,4,8,9 13 | # 14 | # An API may also support applying operations on multiple resource sith a 15 | # single request using this approach 16 | # 17 | # PATCH /posts/3,4,8,9 18 | # DELETE /posts/3,4,8,9 19 | # 20 | # == Usage 21 | # 22 | # Include this +Concern+ in your Action Controller: 23 | # 24 | # SamplesController < ApplicationController 25 | # include APIHelper::Multigettable 26 | # end 27 | # 28 | # or in your Grape API class: 29 | # 30 | # class SampleAPI < Grape::API 31 | # helpers APIHelper::Multigettable 32 | # end 33 | # 34 | # then use the +multiget+ method like this: 35 | # 36 | # @post = multiget(Post, find_by: :id, param: :id, max: 12) 37 | # 38 | # The +multiget+ method returns an collection of or a single model, based 39 | # directly from the requested URL. 40 | # 41 | # There is also another helper method to determine whether the request is 42 | # multigeting or not: 43 | # 44 | # multiget?(param: id) # => true of false 45 | # 46 | module APIHelper::Multigettable 47 | extend ActiveSupport::Concern 48 | 49 | # Get multiple resources from the request by specifing ids split by ',' 50 | # 51 | # Params: 52 | # 53 | # +resource+:: 54 | # +ActiveRecord::Relation+ resource collection to find resources from 55 | # 56 | # +find_by+:: 57 | # +Symbol+ the attribute that is used to find resources, defaults to :id 58 | # 59 | # +param+:: 60 | # +Symbol+ the request parameter name used to find resources, 61 | # defaults to :id 62 | # 63 | # +max+:: 64 | # +Integer+ maxium count of returning results 65 | # 66 | def multiget(resource, find_by: :id, param: :id, max: 10) 67 | ids = params[param].split(',') 68 | ids = ids[0..(max - 1)] 69 | 70 | if ids.count > 1 71 | resource.where(find_by => ids) 72 | else 73 | resource.find_by(find_by => ids[0]) 74 | end 75 | end 76 | 77 | # Is the a multiget request? 78 | def multiget?(param: :id) 79 | params[param].present? && params[param].include?(',') 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/api_helper/paginatable.rb: -------------------------------------------------------------------------------- 1 | require 'active_support' 2 | 3 | # = Paginatable 4 | # 5 | # Paginating the requested items can avoid returning too much data in a single 6 | # response. API clients can iterate over the results with pagination instead of 7 | # rerteving all the data in one time, ruining the database connection or 8 | # network. 9 | # 10 | # There are two available URL parameters: +per_page+ and +page+. The former is 11 | # used for setting how many resources will be returned in each page, there will 12 | # be a maxium limit and default value for each API: 13 | # 14 | # GET /posts?per_page=10 15 | # 16 | # The server will respond 10 resources in a request. 17 | # 18 | # Use the +page+ parameter to specify which to page get: 19 | # 20 | # GET /posts?page=5 21 | # 22 | # Pagination info will be provided in the HTTP Link header like this: 23 | # 24 | # Link: ; rel="first", 25 | # ; rel="prev" 26 | # ; rel="next", 27 | # ; rel="last" 28 | # 29 | # Line breaks are added for readability. 30 | # 31 | # Which follows the proposed RFC 5988 standard. 32 | # 33 | # An aditional header, +X-Items-Count+, will also be set to the total pages 34 | # count. 35 | # 36 | # == Usage 37 | # 38 | # Include this +Concern+ in your Action Controller: 39 | # 40 | # SamplesController < ApplicationController 41 | # include APIHelper::Paginatable 42 | # end 43 | # 44 | # or in your Grape API class: 45 | # 46 | # class SampleAPI < Grape::API 47 | # helpers APIHelper::Paginatable 48 | # end 49 | # 50 | # then set the options for pagination in the grape method, as the following as 51 | # an example: 52 | # 53 | # resources :posts do 54 | # get do 55 | # collection = current_user.posts 56 | # pagination collection.count, default_per_page: 25, maxium_per_page: 100 57 | # 58 | # # ... 59 | # end 60 | # end 61 | # 62 | # Then use the helper methods like this: 63 | # 64 | # # this example uses kaminari 65 | # User.page(pagination_per_page).per(pagination_page) 66 | # 67 | # HTTP Link header will be automatically set by the way. 68 | module APIHelper::Paginatable 69 | extend ActiveSupport::Concern 70 | 71 | # Set pagination for the request 72 | # 73 | # Params: 74 | # 75 | # +items_count+:: 76 | # +Symbol+ name of resource to receive the inclusion 77 | # 78 | # +default_per_page+:: 79 | # +Integer+ default per_page 80 | # 81 | # +maxium_per_page+:: 82 | # +Integer+ maximum results do return on a single page 83 | # 84 | def pagination(items_count, default_per_page: 20, 85 | maxium_per_page: 100, 86 | set_header: true) 87 | items_count = items_count.count if items_count.respond_to? :count 88 | 89 | @pagination_per_page = (params[:per_page] || default_per_page).to_i 90 | @pagination_per_page = maxium_per_page if @pagination_per_page > maxium_per_page 91 | @pagination_per_page = 1 if @pagination_per_page < 1 92 | 93 | items_count = 0 if items_count < 0 94 | pages_count = (items_count.to_f / @pagination_per_page).ceil 95 | pages_count = 1 if pages_count < 1 96 | 97 | @pagination_items_count = items_count 98 | @pagination_pages_count = pages_count 99 | 100 | @pagination_page = (params[:page] || 1).to_i 101 | @pagination_page = pages_count if @pagination_page > pages_count 102 | @pagination_page = 1 if @pagination_page < 1 103 | 104 | if current_page > 1 105 | @pagination_first_page_url = add_or_replace_uri_param(request.url, :page, 1) 106 | @pagination_prev_page_url = add_or_replace_uri_param(request.url, :page, (current_page > pages_count ? pages_count : current_page - 1)) 107 | end 108 | 109 | if current_page < pages_count 110 | @pagination_next_page_url = add_or_replace_uri_param(request.url, :page, current_page + 1) 111 | @pagination_last_page_url = add_or_replace_uri_param(request.url, :page, pages_count) 112 | end 113 | 114 | if set_header 115 | link_headers ||= [] 116 | 117 | if current_page > 1 118 | link_headers << "<#{@pagination_first_page_url}>; rel=\"first\"" if @pagination_first_page_url 119 | link_headers << "<#{@pagination_prev_page_url}>; rel=\"prev\"" if @pagination_prev_page_url 120 | end 121 | 122 | if current_page < pages_count 123 | link_headers << "<#{@pagination_next_page_url}>; rel=\"next\"" if @pagination_next_page_url 124 | link_headers << "<#{@pagination_last_page_url}>; rel=\"last\"" if @pagination_last_page_url 125 | end 126 | 127 | link_header = link_headers.join(', ') 128 | 129 | if self.respond_to?(:header) 130 | self.header('Link', link_header) 131 | self.header('X-Items-Count', items_count.to_s) 132 | self.header('X-Pages-Count', pages_count.to_s) 133 | end 134 | 135 | if defined?(response) && response.respond_to?(:headers) 136 | response.headers['Link'] = link_header 137 | response.headers['X-Items-Count'] = items_count.to_s 138 | response.headers['X-Pages-Count'] = pages_count.to_s 139 | end 140 | end 141 | end 142 | 143 | # Getter for the current page 144 | def pagination_page 145 | @pagination_page 146 | end 147 | 148 | alias_method :current_page, :pagination_page 149 | 150 | # Getter for per_page 151 | def pagination_per_page 152 | @pagination_per_page 153 | end 154 | 155 | def pagination_items_count 156 | @pagination_items_count 157 | end 158 | 159 | def pagination_pages_count 160 | @pagination_pages_count 161 | end 162 | 163 | def pagination_first_page_url 164 | @pagination_first_page_url 165 | end 166 | 167 | def pagination_prev_page_url 168 | @pagination_prev_page_url 169 | end 170 | 171 | def pagination_next_page_url 172 | @pagination_next_page_url 173 | end 174 | 175 | def pagination_last_page_url 176 | @pagination_last_page_url 177 | end 178 | 179 | alias_method :paginate_with, :pagination_per_page 180 | 181 | def add_or_replace_uri_param(url, param_name, param_value) # :nodoc: 182 | uri = URI(url) 183 | params = URI.decode_www_form(uri.query || '') 184 | params.delete_if { |param| param[0].to_s == param_name.to_s } 185 | params << [param_name, param_value] 186 | uri.query = URI.encode_www_form(params) 187 | uri.to_s 188 | end 189 | 190 | # Return the 'per_page' param description 191 | def self.per_page_param_desc 192 | "Specify how many items you want each page to return." 193 | end 194 | 195 | # Return the 'page' param description 196 | def self.page_param_desc 197 | "Specify which page you want to get." 198 | end 199 | end 200 | -------------------------------------------------------------------------------- /lib/api_helper/sortable.rb: -------------------------------------------------------------------------------- 1 | require 'active_support' 2 | 3 | # = Sortable 4 | # 5 | # A Sortable Resource API gives the flexibility to change how the returned data 6 | # is sorted to the client. Clients can use the +sort+ URL parameter to control 7 | # how the returned data is sorted, as this example: 8 | # 9 | # GET /posts?sort=-created_at,title 10 | # 11 | # This means to sort the data by its created time descended and then the title 12 | # ascended. 13 | # 14 | # == Usage 15 | # 16 | # Include this +Concern+ in your Action Controller: 17 | # 18 | # SamplesController < ApplicationController 19 | # include APIHelper::Sortable 20 | # end 21 | # 22 | # or in your Grape API class: 23 | # 24 | # class SampleAPI < Grape::API 25 | # helpers APIHelper::Sortable 26 | # end 27 | # 28 | # then use the +sortable+ method like this: 29 | # 30 | # resources :posts do 31 | # get do 32 | # sortable default_order: { created_at: :desc } 33 | # @posts = Post.order(sortable_sort) 34 | # 35 | # # ... 36 | # end 37 | # end 38 | # 39 | module APIHelper::Sortable 40 | extend ActiveSupport::Concern 41 | 42 | # Gets the +sort+ parameter with the format 'resourses?sort=-created_at,name', 43 | # verify and converts it into an safe Hash that can be passed into the .order 44 | # method. 45 | # 46 | # Params: 47 | # 48 | # +default_order+:: 49 | # +Hash+ the default value to return if the sort parameter is not provided 50 | # 51 | def sortable(default_order: {}) 52 | # get the parameter 53 | sort_by = params[:sort] || params[:sort_by] 54 | 55 | if sort_by.is_a?(String) 56 | # split it 57 | sort_by_attrs = sort_by.gsub(/[^a-zA-Z0-9\-_,]/, '').split(',') 58 | 59 | # save it 60 | @sortable_sort = {} 61 | sort_by_attrs.each do |attrb| 62 | if attrb.match(/^-/) 63 | @sortable_sort[attrb.gsub(/^-/, '')] = :desc 64 | else 65 | @sortable_sort[attrb] = :asc 66 | end 67 | end 68 | else 69 | @sortable_sort = default_order 70 | end 71 | end 72 | 73 | # Helper to get the sort data 74 | def sortable_sort 75 | @sortable_sort 76 | end 77 | 78 | # Return the 'sort' param description 79 | def self.sort_param_desc(example: nil, default: nil) 80 | if default.present? 81 | desc = "Specify how the returning data should be sorted, defaults to '#{default}'." 82 | else 83 | desc = "Specify how the returning data should be sorted." 84 | end 85 | if example.present? 86 | "#{desc} Example value: '#{example}'" 87 | else 88 | desc 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/api_helper/version.rb: -------------------------------------------------------------------------------- 1 | module APIHelper 2 | VERSION = "0.1.3" 3 | end 4 | -------------------------------------------------------------------------------- /spec/api_helper/fieldsettable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe APIHelper::Fieldsettable do 4 | describe ".fields_param_desc" do 5 | it "returns a string" do 6 | expect(APIHelper::Fieldsettable.fields_param_desc).to be_a(String) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/api_helper/filterable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe APIHelper::Filterable do 4 | describe ".filter_param_desc" do 5 | it "returns a string" do 6 | expect(APIHelper::Filterable.filter_param_desc).to be_a(String) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/api_helper/grape/fieldsettable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'grape_helper' 2 | 3 | describe APIHelper::Fieldsettable do 4 | context "used in a Grape app" do 5 | include Rack::Test::Methods 6 | 7 | class FieldsettableAPI < Grape::API 8 | helpers APIHelper::Fieldsettable 9 | 10 | resources :posts do 11 | get do 12 | fieldset_for :post, default: true, 13 | permitted_fields: [:title, :content, :author], 14 | default_fields: [:title, :content] 15 | fieldset_for :user, permitted_fields: [:id, :avatar_url, :name], 16 | defaults_to_permitted_fields: true 17 | 18 | # returns the fieldset directly for examining 19 | return fieldset 20 | end 21 | end 22 | end 23 | 24 | def app 25 | FieldsettableAPI 26 | end 27 | 28 | it "parses the currect fieldsets for each resource" do 29 | get '/posts.json?fields[post]=title,author&fields[user]=name' 30 | json = JSON.parse(last_response.body) 31 | 32 | expect(json).to eq('post' => ['title', 'author'], 'user' => ['name']) 33 | end 34 | 35 | it "uses the default fieldsets if not specified" do 36 | get '/posts.json' 37 | json = JSON.parse(last_response.body) 38 | 39 | expect(json).to eq('post' => ['title', 'content'], 'user' => ['id', 'avatar_url', 'name']) 40 | end 41 | 42 | it "ignores undeclared resource" do 43 | get '/posts.json?fields[post]=title,author&fields[ufo]=radius' 44 | json = JSON.parse(last_response.body) 45 | 46 | expect(json['post']).to eq(['title', 'author']) 47 | expect(json['ufo']).to be_blank 48 | end 49 | 50 | it "does not return unpermitted fields for an resource" do 51 | get '/posts.json?fields[post]=title,author,secret&fields[user]=name,password' 52 | json = JSON.parse(last_response.body) 53 | 54 | expect(json).to eq('post' => ['title', 'author'], 55 | 'user' => ['name']) 56 | expect(json['user']).not_to include('password') 57 | end 58 | end 59 | end if defined?(Grape) 60 | -------------------------------------------------------------------------------- /spec/api_helper/grape/filterable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'grape_helper' 2 | 3 | describe APIHelper::Filterable do 4 | context "used in a Grape app" do 5 | include Rack::Test::Methods 6 | 7 | class FilterableAPI < Grape::API 8 | helpers APIHelper::Filterable 9 | 10 | resources :resources do 11 | get do 12 | collection = filter(Model.all) 13 | return collection 14 | end 15 | end 16 | end 17 | 18 | def app 19 | FilterableAPI 20 | end 21 | 22 | it "filters out resource with a matching attribute" do 23 | get '/resources.json?filter[string]=yo' 24 | json = JSON.parse(last_response.body) 25 | 26 | expect(json).not_to be_blank 27 | json.each do |resource| 28 | expect(resource['string']).to eq('yo') 29 | end 30 | 31 | get '/resources.json?filter[boolean]=true' 32 | json = JSON.parse(last_response.body) 33 | 34 | expect(json).not_to be_blank 35 | json.each do |resource| 36 | expect(resource['boolean']).to be true 37 | end 38 | end 39 | 40 | it "filters out resource with multiple matching attribute" do 41 | get '/resources.json?filter[integer]=1,2,5,7' 42 | json = JSON.parse(last_response.body) 43 | 44 | expect(json).not_to be_blank 45 | json.each do |resource| 46 | expect([1, 2, 5, 7]).to include(resource['integer']) 47 | end 48 | end 49 | 50 | it "filters out resource with the \"not()\" function" do 51 | get '/resources.json?filter[string]=not(yo,hi,boom)' 52 | json = JSON.parse(last_response.body) 53 | 54 | expect(json).not_to be_blank 55 | json.each do |resource| 56 | expect(resource['string']).not_to eq('yo') 57 | expect(resource['string']).not_to eq('hi') 58 | expect(resource['string']).not_to eq('boom') 59 | end 60 | end 61 | 62 | it "filters out resource with the \"greater_then()\" function" do 63 | get '/resources.json?filter[integer]=greater_then(3)' 64 | json = JSON.parse(last_response.body) 65 | 66 | expect(json).not_to be_blank 67 | json.each do |resource| 68 | expect(resource['integer']).to be > 3 69 | end 70 | end 71 | 72 | it "filters out resource with the \"less_then()\" function" do 73 | get '/resources.json?filter[integer]=less_then(3)' 74 | json = JSON.parse(last_response.body) 75 | 76 | expect(json).not_to be_blank 77 | json.each do |resource| 78 | expect(resource['integer']).to be < 3 79 | end 80 | end 81 | 82 | it "filters out resource with the \"greater_then_or_equal()\" function" do 83 | get '/resources.json?filter[integer]=greater_then_or_equal(3)' 84 | json = JSON.parse(last_response.body) 85 | 86 | expect(json).not_to be_blank 87 | json.each do |resource| 88 | expect(resource['integer']).to be >= 3 89 | end 90 | end 91 | 92 | it "filters out resource with the \"less_then_or_equal()\" function" do 93 | get '/resources.json?filter[integer]=less_then_or_equal(3)' 94 | json = JSON.parse(last_response.body) 95 | 96 | expect(json).not_to be_blank 97 | json.each do |resource| 98 | expect(resource['integer']).to be <= 3 99 | end 100 | end 101 | 102 | it "filters out resource with the \"between()\" function" do 103 | get '/resources.json?filter[integer]=between(2,4)' 104 | json = JSON.parse(last_response.body) 105 | 106 | expect(json).not_to be_blank 107 | json.each do |resource| 108 | expect(resource['integer']).to be >= 2 109 | expect(resource['integer']).to be <= 4 110 | end 111 | 112 | get '/resources.json?filter[datetime]=between(1970-1-1,2199-1-1)' 113 | json = JSON.parse(last_response.body) 114 | 115 | expect(json).not_to be_blank 116 | json.each do |resource| 117 | expect(Time.new(resource['datetime'])).to be >= Time.new(1970, 1, 1) 118 | expect(Time.new(resource['datetime'])).to be <= Time.new(2199, 1, 1) 119 | end 120 | end 121 | 122 | it "filters out resource with the \"contains()\" function" do 123 | get '/resources.json?filter[string]=contains(o)' 124 | json = JSON.parse(last_response.body) 125 | 126 | expect(json).not_to be_blank 127 | json.each do |resource| 128 | expect(resource['string']).to include('o') 129 | end 130 | end 131 | 132 | it "filters out resource with the \"null()\" function" do 133 | get '/resources.json?filter[string]=null()' 134 | json = JSON.parse(last_response.body) 135 | 136 | expect(json).not_to be_blank 137 | json.each do |resource| 138 | expect(resource['string']).to eq(nil) 139 | end 140 | 141 | get '/resources.json?filter[boolean]=null()' 142 | json = JSON.parse(last_response.body) 143 | 144 | expect(json).not_to be_blank 145 | json.each do |resource| 146 | expect(resource['boolean']).to eq(nil) 147 | end 148 | end 149 | 150 | it "filters out resource with the \"blank()\" function" do 151 | get '/resources.json?filter[string]=blank()' 152 | json = JSON.parse(last_response.body) 153 | 154 | expect(json).not_to be_blank 155 | json.each do |resource| 156 | expect(resource['string']).to be_blank 157 | end 158 | end 159 | 160 | it "filters out resource with multiple conditions" do 161 | get '/resources.json?filter[integer]=between(2,4)&filter[string]=contains(o)' 162 | json = JSON.parse(last_response.body) 163 | 164 | expect(json).not_to be_blank 165 | json.each do |resource| 166 | expect(resource['integer']).to be_between(2, 4) 167 | expect(resource['string']).to include('o') 168 | end 169 | end 170 | 171 | it "ignores filtering with unknown fields" do 172 | get '/resources.json?filter[string]=%D1%85%D0%BE%D1%80%D0%BE%D1%88%D0%BE&unknown_field=val' 173 | json = JSON.parse(last_response.body) 174 | 175 | expect(json).not_to be_blank 176 | json.each do |resource| 177 | expect(resource['string']).to eq('хорошо') 178 | end 179 | end 180 | 181 | context "with filterable fields specified" do 182 | 183 | class FilterableAPIv2 < Grape::API 184 | helpers APIHelper::Filterable 185 | 186 | resources :resources do 187 | get do 188 | collection = 189 | filter(Model.all, filterable_fields: [:integer, :boolean]) 190 | return collection 191 | end 192 | end 193 | end 194 | 195 | def app 196 | FilterableAPIv2 197 | end 198 | 199 | it "is only filterable with filterable fields" do 200 | get '/resources.json?filter[string]=%D1%85%D0%BE%D1%80%D0%BE%D1%88%D0%BE&integer=5' 201 | json = JSON.parse(last_response.body) 202 | 203 | expect(json).not_to be_blank 204 | expect(json.count).to be > 1 205 | end 206 | end 207 | end 208 | end if defined?(Grape) 209 | -------------------------------------------------------------------------------- /spec/api_helper/grape/includable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'grape_helper' 2 | 3 | describe APIHelper::Includable do 4 | context "used in a Grape app" do 5 | include Rack::Test::Methods 6 | 7 | class IncludableAPI < Grape::API 8 | helpers APIHelper::Includable 9 | 10 | resources :posts do 11 | get do 12 | inclusion_for :post, default: true, default_includes: [:author, :comments] 13 | inclusion_for :user, permitted_includes: [:posts, :followers] 14 | 15 | # returns the inclusion directly for examining 16 | return inclusion 17 | end 18 | end 19 | end 20 | 21 | def app 22 | IncludableAPI 23 | end 24 | 25 | it "parses the included fields for each resource" do 26 | get 'posts.json?include[post]=author,comments,stargazers&include[user]=followers' 27 | json = JSON.parse(last_response.body) 28 | 29 | expect(json).to eq('post' => ['author', 'comments', 'stargazers'], 30 | 'user' => ['followers']) 31 | end 32 | 33 | it "parses the currect fieldsets for the default resource" do 34 | get 'posts.json?include=author,comments,stargazers' 35 | json = JSON.parse(last_response.body) 36 | 37 | expect(json['post']).to eq(['author', 'comments', 'stargazers']) 38 | end 39 | 40 | it "uses the default fieldsets if not specified" do 41 | get 'posts.json' 42 | json = JSON.parse(last_response.body) 43 | 44 | expect(json['post']).to eq(['author', 'comments']) 45 | end 46 | 47 | it "ignores undeclared resource" do 48 | get 'posts.json?include[post]=author&include[ufo]=radius' 49 | json = JSON.parse(last_response.body) 50 | 51 | expect(json['post']).to eq(['author']) 52 | expect(json['ufo']).to be_blank 53 | end 54 | 55 | context "permitted includes is set" do 56 | it "ignores unpermitted fields" do 57 | get 'posts.json?include[post]=author&include[user]=followers,devices' 58 | json = JSON.parse(last_response.body) 59 | 60 | expect(json['post']).to eq(['author']) 61 | expect(json['user']).to eq(['followers']) 62 | end 63 | 64 | it "can be disabled while giving invalid parameters" do 65 | get 'posts.json?include[user]=none' 66 | json = JSON.parse(last_response.body) 67 | 68 | expect(json['user']).to be_blank 69 | end 70 | end 71 | 72 | context "integrated with fieldsettable" do 73 | 74 | class IncludableAPIv2 < Grape::API 75 | helpers APIHelper::Fieldsettable 76 | helpers APIHelper::Includable 77 | 78 | resources :posts do 79 | get do 80 | fieldset_for :post, default: true, default_fields: [:title, :author, :content] 81 | fieldset_for :user 82 | inclusion_for :post, default: true, default_includes: [:author, :comments] 83 | inclusion_for :user, permitted_includes: [:posts, :followers] 84 | 85 | # returns the inclusion directly for examining 86 | return inclusion 87 | end 88 | end 89 | end 90 | 91 | def app 92 | IncludableAPIv2 93 | end 94 | 95 | it "ignores fields not listed in fieldset" do 96 | get 'posts.json?include=author,comments' 97 | json = JSON.parse(last_response.body) 98 | 99 | expect(json['post']).to eq(['author']) 100 | 101 | get 'posts.json?fields=title,author,comments&include=author,comments' 102 | json = JSON.parse(last_response.body) 103 | 104 | expect(json['post']).to eq(['author', 'comments']) 105 | end 106 | 107 | it "includes fields by default" do 108 | get 'posts.json?fields=title,author,comments' 109 | json = JSON.parse(last_response.body) 110 | 111 | expect(json['post']).to eq(['author', 'comments']) 112 | end 113 | end 114 | end 115 | end if defined?(Grape) 116 | -------------------------------------------------------------------------------- /spec/api_helper/grape/multigettable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'grape_helper' 2 | 3 | describe APIHelper::Multigettable do 4 | context "used in a Grape app" do 5 | include Rack::Test::Methods 6 | 7 | class MultigettableAPI < Grape::API 8 | helpers APIHelper::Multigettable 9 | 10 | resources :resources do 11 | get :':id' do 12 | collection = multiget(Model.all) 13 | return collection 14 | end 15 | end 16 | end 17 | 18 | def app 19 | MultigettableAPI 20 | end 21 | 22 | it "can get a specified resource" do 23 | get '/resources/1.json' 24 | json = JSON.parse(last_response.body) 25 | 26 | expect(json['id']).to eq(1) 27 | 28 | get '/resources/2.json' 29 | json = JSON.parse(last_response.body) 30 | 31 | expect(json['id']).to eq(2) 32 | end 33 | 34 | it "can get multiple specified resource" do 35 | get '/resources/1,4,2,5.json' 36 | json = JSON.parse(last_response.body) 37 | 38 | expect(json.count).to eq(4) 39 | json.each do |resource| 40 | expect([1, 4, 2, 5]).to include(resource['id']) 41 | end 42 | end 43 | end 44 | end if defined?(Grape) 45 | -------------------------------------------------------------------------------- /spec/api_helper/grape/paginatable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'grape_helper' 2 | 3 | describe APIHelper::Paginatable do 4 | context "used in a Grape app" do 5 | include Rack::Test::Methods 6 | 7 | class PaginatableAPI < Grape::API 8 | helpers APIHelper::Paginatable 9 | 10 | resources :resources do 11 | get do 12 | pagination 1201, default_per_page: 20, maxium_per_page: 100 13 | return [] 14 | end 15 | end 16 | end 17 | 18 | def app 19 | PaginatableAPI 20 | end 21 | 22 | it "sets the correct HTTP Link response header" do 23 | get 'resources.json' 24 | expect(last_response.header['Link']).to eq('; rel="next", ; rel="last"') 25 | 26 | get 'resources.json?page=2' 27 | expect(last_response.header['Link']).to eq('; rel="first", ; rel="prev", ; rel="next", ; rel="last"') 28 | 29 | get 'resources.json?page=3' 30 | expect(last_response.header['Link']).to eq('; rel="first", ; rel="prev", ; rel="next", ; rel="last"') 31 | 32 | get 'resources.json?page=60' 33 | expect(last_response.header['Link']).to eq('; rel="first", ; rel="prev", ; rel="next", ; rel="last"') 34 | 35 | get 'resources.json?page=61' 36 | expect(last_response.header['Link']).to eq('; rel="first", ; rel="prev"') 37 | 38 | get 'resources.json?page=62' 39 | expect(last_response.header['Link']).to eq('; rel="first", ; rel="prev"') 40 | 41 | get 'resources.json?page=0' 42 | expect(last_response.header['Link']).to eq('; rel="next", ; rel="last"') 43 | 44 | get 'resources.json?per_page=5&page=3' 45 | expect(last_response.header['Link']).to eq('; rel="first", ; rel="prev", ; rel="next", ; rel="last"') 46 | 47 | get 'resources.json?per_page=5000&page=3' 48 | expect(last_response.header['Link']).to eq('; rel="first", ; rel="prev", ; rel="next", ; rel="last"') 49 | 50 | get 'resources.json?per_page=0&page=3' 51 | expect(last_response.header['Link']).to eq('; rel="first", ; rel="prev", ; rel="next", ; rel="last"') 52 | end 53 | 54 | it "sets the correct HTTP X-Items-Count response header" do 55 | get 'resources.json' 56 | expect(last_response.header['X-Items-Count']).to eq('1201') 57 | end 58 | 59 | it "sets the correct HTTP X-Pages-Count response header" do 60 | get 'resources.json' 61 | expect(last_response.header['X-Pages-Count']).to eq('61') 62 | 63 | get 'resources.json?per_page=5&page=3' 64 | expect(last_response.header['X-Pages-Count']).to eq('241') 65 | 66 | get 'resources.json?per_page=5000&page=3' 67 | expect(last_response.header['X-Pages-Count']).to eq('13') 68 | 69 | get 'resources.json?per_page=0&page=3' 70 | expect(last_response.header['X-Pages-Count']).to eq('1201') 71 | end 72 | end 73 | end if defined?(Grape) 74 | -------------------------------------------------------------------------------- /spec/api_helper/grape/sortable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'grape_helper' 2 | 3 | describe APIHelper::Sortable do 4 | context "used in a Grape app" do 5 | include Rack::Test::Methods 6 | 7 | class SortableAPI < Grape::API 8 | helpers APIHelper::Sortable 9 | 10 | resources :resources do 11 | get do 12 | sortable(default_order: { string: :desc }) 13 | collection = Model.order(sortable_sort) 14 | return collection 15 | end 16 | end 17 | end 18 | 19 | def app 20 | SortableAPI 21 | end 22 | 23 | it "sorts the resource with the given order" do 24 | get 'resources.json?sort=integer' 25 | json = JSON.parse(last_response.body) 26 | 27 | expect(json[0]['integer']).to eq(nil) 28 | expect(json[1]['integer']).to eq(nil) 29 | expect(json[2]['integer']).to eq(nil) 30 | expect(json[3]['integer']).to eq(1) 31 | expect(json[4]['integer']).to eq(2) 32 | expect(json[5]['integer']).to eq(3) 33 | 34 | get 'resources.json?sort=-integer' 35 | json = JSON.parse(last_response.body) 36 | 37 | expect(json[0]['integer']).to eq(5) 38 | expect(json[1]['integer']).to eq(5) 39 | expect(json[2]['integer']).to eq(5) 40 | 41 | get 'resources.json?sort=-integer,string' 42 | json = JSON.parse(last_response.body) 43 | 44 | expect(json[0]['integer']).to eq(5) 45 | expect(json[0]['string']).to eq('') 46 | expect(json[1]['integer']).to eq(5) 47 | expect(json[1]['string']).to eq('хорошо') 48 | expect(json[2]['integer']).to eq(5) 49 | expect(json[2]['string']).to eq('好!') 50 | 51 | get 'resources.json?sort=-integer,-string' 52 | json = JSON.parse(last_response.body) 53 | 54 | expect(json[0]['integer']).to eq(5) 55 | expect(json[0]['string']).to eq('好!') 56 | expect(json[1]['integer']).to eq(5) 57 | expect(json[1]['string']).to eq('хорошо') 58 | expect(json[2]['integer']).to eq(5) 59 | expect(json[2]['string']).to eq('') 60 | end 61 | 62 | it "sorts the resource with the default order" do 63 | get 'resources.json' 64 | json = JSON.parse(last_response.body) 65 | 66 | expect(json[0]['string']).to eq('好!') 67 | expect(json[1]['string']).to eq('хорошо') 68 | expect(json[2]['string']).to eq('yo') 69 | end 70 | end 71 | end if defined?(Grape) 72 | -------------------------------------------------------------------------------- /spec/api_helper/includable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe APIHelper::Includable do 4 | describe ".include_param_desc" do 5 | it "returns a string" do 6 | expect(APIHelper::Includable.include_param_desc).to be_a(String) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/api_helper/multigettable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe APIHelper::Multigettable do 4 | end 5 | -------------------------------------------------------------------------------- /spec/api_helper/paginatable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe APIHelper::Paginatable do 4 | describe ".per_page_param_desc" do 5 | it "returns a string" do 6 | expect(APIHelper::Paginatable.per_page_param_desc).to be_a(String) 7 | end 8 | end 9 | 10 | describe ".page_param_desc" do 11 | it "returns a string" do 12 | expect(APIHelper::Paginatable.page_param_desc).to be_a(String) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/api_helper/rails/fieldsettable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe APIHelper::Fieldsettable do 4 | context "used in a Rails controller", :type => :controller do 5 | 6 | context "with specified fieldsets" do 7 | 8 | controller(TestRailsApp::ApplicationController) do 9 | include APIHelper::Fieldsettable 10 | 11 | def index 12 | fieldset_for :post, default: true, default_fields: [:title, :content] 13 | fieldset_for :user 14 | render json: [] 15 | end 16 | end 17 | 18 | it "parses the fieldsets for each resource" do 19 | # GET /?fields[post]=title,author&fields[user]=name 20 | get :index, fields: { post: 'title,author', user: 'name' } 21 | 22 | expect(controller.fieldset).to eq('post' => ['title', 'author'], 23 | 'user' => ['name']) 24 | expect(controller.fieldset[:post]).to eq(['title', 'author']) 25 | expect(controller.fieldset(:post)).to eq(['title', 'author']) 26 | expect(controller.fieldset(:post, :author)).to be true 27 | expect(controller.fieldset(:post, 'author')).to be true 28 | expect(controller.fieldset('post', :author)).to be true 29 | expect(controller.fieldset('post', 'author')).to be true 30 | expect(controller.fieldset(:post, :content)).to be false 31 | 32 | expect(controller.fieldset(:nothing)).to eq([]) 33 | end 34 | 35 | it "parses the currect fieldsets for the default resource" do 36 | # GET /?fields=title,author 37 | get :index, fields: 'title,author' 38 | 39 | expect(controller.fieldset[:post]).to eq(['title', 'author']) 40 | expect(controller.fieldset(:post)).to eq(['title', 'author']) 41 | expect(controller.fieldset(:post, :author)).to be true 42 | expect(controller.fieldset(:post, 'author')).to be true 43 | expect(controller.fieldset('post', :author)).to be true 44 | expect(controller.fieldset('post', 'author')).to be true 45 | expect(controller.fieldset(:post, :content)).to be false 46 | end 47 | 48 | it "uses the default fieldsets if not specified" do 49 | # GET / 50 | get :index 51 | 52 | expect(controller.fieldset[:post]).to eq(['title', 'content']) 53 | expect(controller.fieldset(:post)).to eq(['title', 'content']) 54 | expect(controller.fieldset(:post, :content)).to be true 55 | expect(controller.fieldset(:user)).to be_blank 56 | end 57 | 58 | it "ignores undeclared resource" do 59 | # GET /?fields[post]=title,author&fields[ufo]=radius 60 | get :index, fields: { post: 'title,author', ufo: 'radius' } 61 | 62 | expect(controller.fieldset(:post)).to eq(['title', 'author']) 63 | expect(controller.fieldset(:ufo)).to be_blank 64 | end 65 | 66 | describe "view helpers" do 67 | describe "set_fieldset" do 68 | it "sets the properties of fieldset" do 69 | # GET /?fields[post]=title,author,secret 70 | get :index, fields: { post: 'title,author,secret' } 71 | 72 | # These methods are normally be called in the view 73 | controller.set_fieldset :post, permitted_fields: [:title, :content, :author] 74 | controller.set_fieldset :user, default_fields: [:avatar_url, :name] 75 | 76 | expect(controller.fieldset(:post)).to eq(['title', 'author']) 77 | expect(controller.fieldset(:user)).to eq(['avatar_url', 'name']) 78 | end 79 | end 80 | end 81 | end 82 | 83 | context "with specified permitted_fields fieldsets" do 84 | 85 | controller(TestRailsApp::ApplicationController) do 86 | include APIHelper::Fieldsettable 87 | 88 | def index 89 | fieldset_for :post, default: true, 90 | permitted_fields: [:title, :content, :author], 91 | default_fields: [:title, :content] 92 | fieldset_for :user, permitted_fields: [:id, :avatar_url, :name], 93 | defaults_to_permitted_fields: true 94 | 95 | render json: [] 96 | end 97 | end 98 | 99 | it "does not return unpermitted fields for an resource" do 100 | # GET /?fields[post]=title,author,secret&fields[user]=name,password 101 | get :index, fields: { post: 'title,author,secret', user: 'name,password' } 102 | 103 | expect(controller.fieldset).to eq('post' => ['title', 'author'], 104 | 'user' => ['name']) 105 | expect(controller.fieldset(:user, :password)).to be false 106 | end 107 | 108 | it "uses the default fieldsets if not specified" do 109 | # GET / 110 | get :index 111 | 112 | expect(controller.fieldset(:user)).to eq(%w(id avatar_url name)) 113 | end 114 | end 115 | end 116 | end if defined?(Rails) 117 | -------------------------------------------------------------------------------- /spec/api_helper/rails/filterable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe APIHelper::Filterable do 4 | context "used in a Rails controller", :type => :controller do 5 | 6 | controller(TestRailsApp::ApplicationController) do 7 | include APIHelper::Filterable 8 | 9 | def index 10 | collection = filter(Model.all) 11 | render json: collection 12 | end 13 | end 14 | 15 | it "filters out resource with a matching attribute" do 16 | # GET /?filter[string]=yo 17 | get :index, filter: { string: 'yo' } 18 | json = JSON.parse(response.body) 19 | 20 | expect(json).not_to be_blank 21 | json.each do |resource| 22 | expect(resource['string']).to eq('yo') 23 | end 24 | 25 | # GET /?filter[boolean]=true 26 | get :index, filter: { boolean: 'true' } 27 | json = JSON.parse(response.body) 28 | 29 | expect(json).not_to be_blank 30 | json.each do |resource| 31 | expect(resource['boolean']).to be true 32 | end 33 | end 34 | 35 | it "filters out resource with multiple matching attribute" do 36 | # GET /?filter[integer]=1,2,5,7 37 | get :index, filter: { integer: '1,2,5,7' } 38 | json = JSON.parse(response.body) 39 | 40 | expect(json).not_to be_blank 41 | json.each do |resource| 42 | expect([1, 2, 5, 7]).to include(resource['integer']) 43 | end 44 | end 45 | 46 | it "filters out resource with the \"not()\" function" do 47 | # GET /?filter[string]=not(yo,hi,boom) 48 | get :index, filter: { string: 'not(yo,hi,boom)' } 49 | json = JSON.parse(response.body) 50 | 51 | expect(json).not_to be_blank 52 | json.each do |resource| 53 | expect(resource['string']).not_to eq('yo') 54 | expect(resource['string']).not_to eq('hi') 55 | expect(resource['string']).not_to eq('boom') 56 | end 57 | end 58 | 59 | it "filters out resource with the \"greater_then()\" function" do 60 | # GET /?filter[integer]=greater_then(3) 61 | get :index, filter: { integer: 'greater_then(3)' } 62 | json = JSON.parse(response.body) 63 | 64 | expect(json).not_to be_blank 65 | json.each do |resource| 66 | expect(resource['integer']).to be > 3 67 | end 68 | end 69 | 70 | it "filters out resource with the \"less_then()\" function" do 71 | # GET /?filter[integer]=less_then(3) 72 | get :index, filter: { integer: 'less_then(3)' } 73 | json = JSON.parse(response.body) 74 | 75 | expect(json).not_to be_blank 76 | json.each do |resource| 77 | expect(resource['integer']).to be < 3 78 | end 79 | end 80 | 81 | it "filters out resource with the \"greater_then_or_equal()\" function" do 82 | # GET /?filter[integer]=greater_then_or_equal(3) 83 | get :index, filter: { integer: 'greater_then_or_equal(3)' } 84 | json = JSON.parse(response.body) 85 | 86 | expect(json).not_to be_blank 87 | json.each do |resource| 88 | expect(resource['integer']).to be >= 3 89 | end 90 | end 91 | 92 | it "filters out resource with the \"less_then_or_equal()\" function" do 93 | # GET /?filter[integer]=less_then_or_equal(3) 94 | get :index, filter: { integer: 'less_then_or_equal(3)' } 95 | json = JSON.parse(response.body) 96 | 97 | expect(json).not_to be_blank 98 | json.each do |resource| 99 | expect(resource['integer']).to be <= 3 100 | end 101 | end 102 | 103 | it "filters out resource with the \"between()\" function" do 104 | # GET /?filter[integer]=between(2,4) 105 | get :index, filter: { integer: 'between(2,4)' } 106 | json = JSON.parse(response.body) 107 | 108 | expect(json).not_to be_blank 109 | json.each do |resource| 110 | expect(resource['integer']).to be >= 2 111 | expect(resource['integer']).to be <= 4 112 | end 113 | 114 | # GET /?filter[datetime]=between(1970-1-1,2199-1-1) 115 | get :index, filter: { datetime: 'between(1970-1-1,2199-1-1)' } 116 | json = JSON.parse(response.body) 117 | 118 | expect(json).not_to be_blank 119 | json.each do |resource| 120 | expect(Time.new(resource['datetime'])).to be >= Time.new(1970, 1, 1) 121 | expect(Time.new(resource['datetime'])).to be <= Time.new(2199, 1, 1) 122 | end 123 | end 124 | 125 | it "filters out resource with the \"contains()\" function" do 126 | # GET /?filter[string]=contains(o) 127 | get :index, filter: { string: 'contains(o)' } 128 | json = JSON.parse(response.body) 129 | 130 | expect(json).not_to be_blank 131 | json.each do |resource| 132 | expect(resource['string']).to include('o') 133 | end 134 | end 135 | 136 | it "filters out resource with the \"null()\" function" do 137 | # GET /?filter[string]=null() 138 | get :index, filter: { string: 'null()' } 139 | json = JSON.parse(response.body) 140 | 141 | expect(json).not_to be_blank 142 | json.each do |resource| 143 | expect(resource['string']).to eq(nil) 144 | end 145 | 146 | # GET /?filter[boolean]=null() 147 | get :index, filter: { boolean: 'null()' } 148 | json = JSON.parse(response.body) 149 | 150 | expect(json).not_to be_blank 151 | json.each do |resource| 152 | expect(resource['boolean']).to eq(nil) 153 | end 154 | end 155 | 156 | it "filters out resource with the \"blank()\" function" do 157 | # GET /?filter[string]=blank() 158 | get :index, filter: { string: 'blank()' } 159 | json = JSON.parse(response.body) 160 | 161 | expect(json).not_to be_blank 162 | json.each do |resource| 163 | expect(resource['string']).to be_blank 164 | end 165 | end 166 | 167 | it "filters out resource with multiple conditions" do 168 | # GET /?filter[integer]=between(2,4)&filter[string]=contains(o) 169 | get :index, filter: { integer: 'between(2,4)', string: 'contains(o)' } 170 | json = JSON.parse(response.body) 171 | 172 | expect(json).not_to be_blank 173 | json.each do |resource| 174 | expect(resource['integer']).to be_between(2, 4) 175 | expect(resource['string']).to include('o') 176 | end 177 | end 178 | 179 | it "ignores filtering with unknown fields" do 180 | # GET /?filter[string]=хорошо&unknown_field=val 181 | get :index, filter: { string: 'хорошо', unknown_field: 'val' } 182 | json = JSON.parse(response.body) 183 | 184 | expect(json).not_to be_blank 185 | json.each do |resource| 186 | expect(resource['string']).to eq('хорошо') 187 | end 188 | end 189 | 190 | context "with filterable fields specified" do 191 | 192 | controller(TestRailsApp::ApplicationController) do 193 | include APIHelper::Filterable 194 | 195 | def index 196 | collection = 197 | filter(Model.all, filterable_fields: [:integer, :boolean]) 198 | render json: collection 199 | end 200 | end 201 | 202 | it "is only filterable with filterable fields" do 203 | # GET /?filter[string]=хорошо&integer=5 204 | get :index, filter: { string: 'хорошо', integer: '5' } 205 | json = JSON.parse(response.body) 206 | 207 | expect(json).not_to be_blank 208 | expect(json.count).to be > 1 209 | end 210 | end 211 | end 212 | end if defined?(Rails) 213 | -------------------------------------------------------------------------------- /spec/api_helper/rails/includable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe APIHelper::Includable do 4 | context "used in a Rails controller", :type => :controller do 5 | 6 | controller(TestRailsApp::ApplicationController) do 7 | include APIHelper::Includable 8 | 9 | def index 10 | inclusion_for :post, default: true, default_includes: [:author, :comments] 11 | inclusion_for :user, permitted_includes: [:posts, :followers] 12 | render json: [] 13 | end 14 | end 15 | 16 | it "parses the included fields for each resource" do 17 | # GET /?include[post]=author,comments,stargazers&include[user]=followers 18 | get :index, include: { post: 'author,comments,stargazers', user: 'followers' } 19 | 20 | expect(controller.inclusion).to eq('post' => ['author', 'comments', 'stargazers'], 21 | 'user' => ['followers']) 22 | expect(controller.inclusion[:post]).to eq(['author', 'comments', 'stargazers']) 23 | expect(controller.inclusion(:user)).to eq(['followers']) 24 | expect(controller.inclusion(:post, :author)).to be true 25 | expect(controller.inclusion(:post, 'author')).to be true 26 | expect(controller.inclusion('post', :author)).to be true 27 | expect(controller.inclusion('post', 'author')).to be true 28 | expect(controller.inclusion(:post, :board)).to be false 29 | 30 | expect(controller.inclusion(:nothing)).to eq([]) 31 | end 32 | 33 | it "parses the currect fieldsets for the default resource" do 34 | # GET /?include=author,comments,stargazers 35 | get :index, include: 'author,comments,stargazers' 36 | 37 | expect(controller.inclusion[:post]).to eq(['author', 'comments', 'stargazers']) 38 | expect(controller.inclusion(:post)).to eq(['author', 'comments', 'stargazers']) 39 | expect(controller.inclusion(:post, :author)).to be true 40 | expect(controller.inclusion(:post, 'author')).to be true 41 | expect(controller.inclusion('post', :author)).to be true 42 | expect(controller.inclusion('post', 'author')).to be true 43 | expect(controller.inclusion(:post, :board)).to be false 44 | end 45 | 46 | it "uses the default fieldsets if not specified" do 47 | # GET / 48 | get :index 49 | 50 | expect(controller.inclusion[:post]).to eq(['author', 'comments']) 51 | expect(controller.inclusion(:post)).to eq(['author', 'comments']) 52 | expect(controller.inclusion(:post, :author)).to be true 53 | expect(controller.inclusion(:user)).to be_blank 54 | end 55 | 56 | it "ignores undeclared resource" do 57 | # GET /?include[post]=author&include[ufo]=radius 58 | get :index, include: { post: 'author', ufo: 'passengers' } 59 | 60 | expect(controller.inclusion(:post)).to eq(['author']) 61 | expect(controller.inclusion(:ufo)).to be_blank 62 | end 63 | 64 | context "permitted includes is set" do 65 | it "ignores unpermitted fields" do 66 | # GET /?include[post]=author&include[user]=followers,devices 67 | get :index, include: { post: 'author', user: 'followers,devices' } 68 | 69 | expect(controller.inclusion(:post)).to eq(['author']) 70 | expect(controller.inclusion(:user)).to eq(['followers']) 71 | expect(controller.inclusion(:user, :devices)).to be false 72 | end 73 | 74 | it "can be disabled while giving invalid parameters" do 75 | # GET /?include[user]=none 76 | get :index, include: { user: 'none' } 77 | 78 | expect(controller.inclusion(:user)).to be_blank 79 | end 80 | end 81 | 82 | context "integrated with fieldsettable" do 83 | 84 | controller(TestRailsApp::ApplicationController) do 85 | include APIHelper::Fieldsettable 86 | include APIHelper::Includable 87 | 88 | def index 89 | fieldset_for :post, default: true, default_fields: [:title, :author, :content] 90 | fieldset_for :user 91 | inclusion_for :post, default: true, default_includes: [:author, :comments] 92 | inclusion_for :user, permitted_includes: [:posts, :followers] 93 | render json: [] 94 | end 95 | end 96 | 97 | it "ignores fields not listed in fieldset" do 98 | # GET /?include=author,comments 99 | get :index, include: 'author,comments' 100 | 101 | expect(controller.inclusion(:post)).to eq(['author']) 102 | expect(controller.inclusion(:post, :comments)).to be false 103 | 104 | # GET /?fields=title,author,comments&include=author,comments 105 | get :index, fields: 'title,author,comments', 106 | include: 'author,comments' 107 | 108 | expect(controller.inclusion(:post)).to eq(['author', 'comments']) 109 | expect(controller.inclusion(:post, :comments)).to be true 110 | 111 | expect(controller.fieldset(:post)).to eq(['title', 'author', 'comments']) 112 | end 113 | 114 | it "ignores default inclusion fields not listed in fieldset" do 115 | # GET /?fields=title,author,comments 116 | get :index, fields: 'title,author' 117 | # This method is normally be called in the view 118 | controller.set_inclusion :post, 119 | default_includes: [:author, :comments] 120 | expect(controller.inclusion(:post, :author)).to be true 121 | expect(controller.inclusion(:post, :comments)).to be false 122 | end 123 | 124 | it "includes fields by default" do 125 | # GET /?fields=title,author,comments 126 | get :index, fields: 'title,author,comments' 127 | 128 | expect(controller.inclusion(:post)).to eq(['author', 'comments']) 129 | expect(controller.inclusion(:post, :comments)).to be true 130 | 131 | expect(controller.fieldset(:post)).to eq(['title', 'author', 'comments']) 132 | end 133 | end 134 | 135 | describe "view helpers" do 136 | describe "set_inclusion" do 137 | it "sets the properties of inclusion" do 138 | # GET /?include[post]=author,comments,board 139 | get :index, include: { post: 'author,comments,board' } 140 | 141 | # This method is normally be called in the view 142 | controller.set_inclusion :post, default_includes: [:author], 143 | permitted_includes: [:author, :comments] 144 | 145 | expect(controller.inclusion(:post)).to eq(['author', 'comments']) 146 | 147 | # GET / 148 | get :index 149 | 150 | # This method is normally be called in the view 151 | controller.set_inclusion :user, default_includes: [:post] 152 | 153 | expect(controller.inclusion(:user)).to eq(['post']) 154 | 155 | # GET /?include=false 156 | get :index 157 | 158 | # This method is normally be called in the view 159 | controller.set_inclusion :post, default_includes: [:author], 160 | permitted_includes: [:author, :comments] 161 | 162 | expect(controller.inclusion(:post)).to be_blank 163 | end 164 | end 165 | 166 | describe "set_inclusion_field" do 167 | it "sets the properties of includable fields" do 168 | # These methods are normally called in the view 169 | controller.set_inclusion_field :post, :comments, :comment_ids, 170 | resource_name: :comment, 171 | resources_url: '/comments' 172 | controller.set_inclusion_field :post, :author, :author_id, 173 | resource_name: :user, 174 | resources_url: '/users' 175 | 176 | expect(controller.instance_variable_get(:'@inclusion_field')).to \ 177 | eq('post' => { 178 | 'comments' => { 179 | 'field' => :comments, 180 | 'id_field' => :comment_ids, 181 | 'resource_name' => :comment, 182 | 'resources_url' => '/comments' 183 | }, 184 | 'author' => { 185 | 'field' => :author, 186 | 'id_field' => :author_id, 187 | 'resource_name' => :user, 188 | 'resources_url' => '/users' 189 | } 190 | }) 191 | end 192 | end 193 | 194 | describe "inclusion_field" do 195 | it "get the properties of includable fields" do 196 | # These methods are normally called in the view 197 | controller.set_inclusion_field :post, :comments, :comment_ids, 198 | resource_name: :comment, 199 | resources_url: '/comments' 200 | controller.set_inclusion_field :post, :author, :author_id, 201 | resource_name: :user, 202 | resources_url: '/users' 203 | 204 | expect(controller.inclusion_field).to \ 205 | eq('post' => { 206 | 'comments' => { 207 | 'field' => :comments, 208 | 'id_field' => :comment_ids, 209 | 'resource_name' => :comment, 210 | 'resources_url' => '/comments' 211 | }, 212 | 'author' => { 213 | 'field' => :author, 214 | 'id_field' => :author_id, 215 | 'resource_name' => :user, 216 | 'resources_url' => '/users' 217 | } 218 | }) 219 | 220 | expect(controller.inclusion_field(:post)).to \ 221 | eq('comments' => { 222 | 'field' => :comments, 223 | 'id_field' => :comment_ids, 224 | 'resource_name' => :comment, 225 | 'resources_url' => '/comments' 226 | }, 227 | 'author' => { 228 | 'field' => :author, 229 | 'id_field' => :author_id, 230 | 'resource_name' => :user, 231 | 'resources_url' => '/users' 232 | }) 233 | 234 | expect(controller.inclusion_field(:nothing)).to eq({}) 235 | end 236 | end 237 | end 238 | end 239 | end if defined?(Rails) 240 | -------------------------------------------------------------------------------- /spec/api_helper/rails/multigettable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe APIHelper::Multigettable do 4 | context "used in a Rails controller", :type => :controller do 5 | 6 | controller(TestRailsApp::ApplicationController) do 7 | include APIHelper::Multigettable 8 | 9 | def index 10 | collection = multiget(Model.all) 11 | render json: collection 12 | end 13 | end 14 | 15 | it "can get a specified resource" do 16 | # GET /1 17 | get :index, id: 1 18 | json = JSON.parse(response.body) 19 | 20 | expect(json['id']).to eq(1) 21 | 22 | # GET /2 23 | get :index, id: 2 24 | json = JSON.parse(response.body) 25 | 26 | expect(json['id']).to eq(2) 27 | 28 | expect(controller.multiget?).to be false 29 | end 30 | 31 | it "can get multiple specified resource" do 32 | # GET /1,4,2,5 33 | get :index, id: '1,4,2,5' 34 | json = JSON.parse(response.body) 35 | 36 | expect(json.count).to eq(4) 37 | json.each do |resource| 38 | expect([1, 4, 2, 5]).to include(resource['id']) 39 | end 40 | 41 | expect(controller.multiget?).to be true 42 | end 43 | 44 | context "with maximum resource count limitations" do 45 | 46 | controller(TestRailsApp::ApplicationController) do 47 | include APIHelper::Multigettable 48 | 49 | def index 50 | collection = multiget(Model.all, max: 3) 51 | render json: collection 52 | end 53 | end 54 | 55 | it "limits the maximum resources returned" do 56 | # GET /1,4,2,5 57 | get :index, id: '1,4,2,5' 58 | json = JSON.parse(response.body) 59 | 60 | expect(json.count).to eq(3) 61 | end 62 | end 63 | 64 | context "with custom ids" do 65 | 66 | controller(TestRailsApp::ApplicationController) do 67 | include APIHelper::Multigettable 68 | 69 | def index 70 | collection = multiget(Model.all, find_by: :string, param: :code) 71 | render json: collection 72 | end 73 | end 74 | 75 | it "can get a specified resource" do 76 | get :index, code: 'yo' 77 | json = JSON.parse(response.body) 78 | 79 | expect(json['string']).to eq('yo') 80 | 81 | expect(controller.multiget?).to be false 82 | expect(controller.multiget?(param: :code)).to be false 83 | end 84 | 85 | it "can get multiple specified resource" do 86 | get :index, code: 'hi,yo' 87 | json = JSON.parse(response.body) 88 | 89 | expect(json.count).to eq(2) 90 | json.each do |resource| 91 | expect(%w(hi yo)).to include(resource['string']) 92 | end 93 | 94 | expect(controller.multiget?).to be false 95 | expect(controller.multiget?(param: :code)).to be true 96 | end 97 | end 98 | end 99 | end if defined?(Rails) 100 | -------------------------------------------------------------------------------- /spec/api_helper/rails/paginatable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe APIHelper::Paginatable do 4 | context "used in a Rails controller", :type => :controller do 5 | 6 | controller(TestRailsApp::ApplicationController) do 7 | include APIHelper::Paginatable 8 | 9 | def index 10 | pagination 1201, default_per_page: 20, maxium_per_page: 100 11 | render json: [] 12 | end 13 | end 14 | 15 | it "sets the correct HTTP Link response header" do 16 | # GET / 17 | get :index 18 | expect(response.header['Link']).to eq('; rel="next", ; rel="last"') 19 | 20 | # GET /?page=2 21 | get :index, page: 2 22 | expect(response.header['Link']).to eq('; rel="first", ; rel="prev", ; rel="next", ; rel="last"') 23 | 24 | # GET /?page=3 25 | get :index, page: 3 26 | expect(response.header['Link']).to eq('; rel="first", ; rel="prev", ; rel="next", ; rel="last"') 27 | 28 | # GET /?page=60 29 | get :index, page: 60 30 | expect(response.header['Link']).to eq('; rel="first", ; rel="prev", ; rel="next", ; rel="last"') 31 | 32 | # GET /?page=61 33 | get :index, page: 61 34 | expect(response.header['Link']).to eq('; rel="first", ; rel="prev"') 35 | 36 | # GET /?page=62 37 | get :index, page: 62 38 | expect(response.header['Link']).to eq('; rel="first", ; rel="prev"') 39 | 40 | # GET /?page=0 41 | get :index, page: 0 42 | expect(response.header['Link']).to eq('; rel="next", ; rel="last"') 43 | 44 | # GET /?per_page=5&page=3 45 | # get :index, per_page: 5, page: 3 46 | # expect(response.header['Link']).to eq('; rel="first", ; rel="prev", ; rel="next", ; rel="last"') 47 | 48 | # GET /?per_page=5000&page=3 49 | get :index, per_page: 5000, page: 3 50 | # expect(response.header['Link']).to eq('; rel="first", ; rel="prev", ; rel="next", ; rel="last"') 51 | 52 | # GET /?per_page=0&page=3 53 | get :index, per_page: 0, page: 3 54 | # expect(response.header['Link']).to eq('; rel="first", ; rel="prev", ; rel="next", ; rel="last"') 55 | end 56 | 57 | it "sets the correct HTTP X-Items-Count response header" do 58 | # GET / 59 | get :index 60 | expect(response.header['X-Items-Count']).to eq('1201') 61 | end 62 | 63 | it "sets the correct HTTP X-Pages-Count response header" do 64 | # GET / 65 | get :index 66 | expect(response.header['X-Pages-Count']).to eq('61') 67 | 68 | # GET /?per_page=5&page=3 69 | get :index, per_page: 5, page: 3 70 | expect(response.header['X-Pages-Count']).to eq('241') 71 | 72 | # GET /?per_page=5000&page=3 73 | get :index, per_page: 5000, page: 3 74 | expect(response.header['X-Pages-Count']).to eq('13') 75 | 76 | # GET /?per_page=0&page=3 77 | get :index, per_page: 0, page: 3 78 | expect(response.header['X-Pages-Count']).to eq('1201') 79 | end 80 | 81 | describe "helper methods" do 82 | describe "pagination_per_page" do 83 | it "returns page of the request" do 84 | # GET / 85 | get :index 86 | expect(controller.pagination_per_page).to eq(20) 87 | 88 | # GET /?page=3 89 | get :index, page: 3 90 | expect(controller.pagination_per_page).to eq(20) 91 | 92 | # GET /?page=25 93 | get :index, per_page: 25 94 | expect(controller.pagination_per_page).to eq(25) 95 | 96 | # GET /?per_page=5000 97 | get :index, per_page: 5000 98 | expect(controller.pagination_per_page).to eq(100) 99 | 100 | # GET /?per_page=0 101 | get :index, per_page: 0 102 | expect(controller.pagination_per_page).to eq(1) 103 | end 104 | end 105 | 106 | describe "pagination_page" do 107 | it "returns page of the request" do 108 | # GET / 109 | get :index 110 | expect(controller.pagination_page).to eq(1) 111 | 112 | # GET /?page=3 113 | get :index, page: 3 114 | expect(controller.pagination_page).to eq(3) 115 | 116 | # GET /?page=0 117 | get :index, page: 0 118 | expect(controller.pagination_page).to eq(1) 119 | 120 | # GET /?per_page=5000&page=3 121 | get :index, per_page: 5000, page: 14 122 | expect(controller.pagination_page).to eq(13) 123 | end 124 | end 125 | end 126 | end 127 | end if defined?(Rails) 128 | -------------------------------------------------------------------------------- /spec/api_helper/rails/sortable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe APIHelper::Sortable do 4 | context "used in a Rails controller", :type => :controller do 5 | 6 | controller(TestRailsApp::ApplicationController) do 7 | include APIHelper::Sortable 8 | 9 | def index 10 | sortable(default_order: { string: :desc }) 11 | collection = Model.order(sortable_sort) 12 | render json: collection 13 | end 14 | end 15 | 16 | it "sorts the resource with the given order" do 17 | # GET /?sort=integer 18 | get :index, sort: 'integer' 19 | json = JSON.parse(response.body) 20 | 21 | expect(json[0]['integer']).to eq(nil) 22 | expect(json[1]['integer']).to eq(nil) 23 | expect(json[2]['integer']).to eq(nil) 24 | expect(json[3]['integer']).to eq(1) 25 | expect(json[4]['integer']).to eq(2) 26 | expect(json[5]['integer']).to eq(3) 27 | 28 | # GET /?sort=-integer 29 | get :index, sort: '-integer' 30 | json = JSON.parse(response.body) 31 | 32 | expect(json[0]['integer']).to eq(5) 33 | expect(json[1]['integer']).to eq(5) 34 | expect(json[2]['integer']).to eq(5) 35 | 36 | # GET /?sort=-integer,string 37 | get :index, sort: '-integer,string' 38 | json = JSON.parse(response.body) 39 | 40 | expect(json[0]['integer']).to eq(5) 41 | expect(json[0]['string']).to eq('') 42 | expect(json[1]['integer']).to eq(5) 43 | expect(json[1]['string']).to eq('хорошо') 44 | expect(json[2]['integer']).to eq(5) 45 | expect(json[2]['string']).to eq('好!') 46 | 47 | # GET /?sort=-integer,-string 48 | get :index, sort: '-integer,-string' 49 | json = JSON.parse(response.body) 50 | 51 | expect(json[0]['integer']).to eq(5) 52 | expect(json[0]['string']).to eq('好!') 53 | expect(json[1]['integer']).to eq(5) 54 | expect(json[1]['string']).to eq('хорошо') 55 | expect(json[2]['integer']).to eq(5) 56 | expect(json[2]['string']).to eq('') 57 | end 58 | 59 | it "sorts the resource with the default order" do 60 | # GET / 61 | get :index 62 | json = JSON.parse(response.body) 63 | 64 | expect(json[0]['string']).to eq('好!') 65 | expect(json[1]['string']).to eq('хорошо') 66 | expect(json[2]['string']).to eq('yo') 67 | end 68 | end 69 | end if defined?(Rails) 70 | -------------------------------------------------------------------------------- /spec/api_helper/sortable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe APIHelper::Sortable do 4 | describe ".sort_param_desc" do 5 | it "returns a string" do 6 | expect(APIHelper::Sortable.sort_param_desc).to be_a(String) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/api_helper_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe APIHelper do 4 | it 'has a version number' do 5 | expect(APIHelper::VERSION).not_to be nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/grape_helper.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'json' 3 | require 'byebug' 4 | 5 | begin 6 | require 'grape' 7 | require 'rack/test' 8 | rescue LoadError 9 | end 10 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | begin 4 | require 'rails' 5 | require 'action_controller/railtie' 6 | require 'rspec/rails' 7 | rescue LoadError 8 | else 9 | module TestRailsApp 10 | class Application < Rails::Application 11 | config.secret_token = SecureRandom.hex 12 | config.secret_key_base = SecureRandom.hex 13 | end 14 | 15 | class ApplicationController < ActionController::Base 16 | extend RSpec::Rails::ControllerExampleGroup::BypassRescue 17 | include Rails.application.routes.url_helpers 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/shared_context/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zetavg/api_helper/05c1496f966be643a1f1390fa31598e286d6a411/spec/shared_context/.keep -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | 3 | SimpleCov.profiles.define :gem do 4 | add_filter '/test/' 5 | add_filter '/features/' 6 | add_filter '/spec/' 7 | add_filter '/autotest/' 8 | 9 | add_group 'Binaries', '/bin/' 10 | add_group 'Libraries', '/lib/' 11 | add_group 'Extensions', '/ext/' 12 | add_group 'Vendor Libraries', '/vendor/' 13 | end 14 | 15 | begin 16 | require 'rails' 17 | rescue LoadError 18 | else 19 | require 'coveralls' 20 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[ 21 | SimpleCov::Formatter::HTMLFormatter, 22 | Coveralls::SimpleCov::Formatter 23 | ] 24 | end 25 | 26 | SimpleCov.start :gem 27 | 28 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 29 | require 'api_helper' 30 | 31 | Dir[File.expand_path('../support/**/*.rb', __FILE__)].each { |f| require f } 32 | Dir[File.expand_path('../shared_context/**/*.rb', __FILE__)].each { |f| require f } 33 | 34 | require 'byebug' 35 | -------------------------------------------------------------------------------- /spec/support/active_record.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | 3 | class ActiveRecord::Base 4 | establish_connection adapter: 'sqlite3', 5 | database: 'tmp/db.sqlite3' 6 | end 7 | 8 | class Model < ActiveRecord::Base 9 | end 10 | 11 | table_name = SecureRandom.hex 12 | Model.table_name = table_name 13 | 14 | migration = ActiveRecord::Migration.new 15 | 16 | migration.create_table table_name do |t| 17 | t.integer 'integer' 18 | t.string 'string' 19 | t.datetime 'datetime' 20 | t.boolean 'boolean' 21 | t.text 'text' 22 | end 23 | 24 | integers = [1, 2, 3, 4, 5, 5, 5] 25 | strings = ['yo', 'hi', 'hello', 'hola', '好!', 'хорошо', ''] 26 | datetimes = [DateTime.new(1900, 1, 1), DateTime.new(2000, 1, 1), DateTime.new(2100, 1, 1)] 27 | booleans = [true, false] 28 | texts = %w(yo hi hello) 29 | 30 | 10.times do |i| 31 | m = Model.new(integer: integers[i], 32 | string: strings[i], 33 | datetime: datetimes[i], 34 | boolean: booleans[i], 35 | text: texts[i]) 36 | m.save! 37 | end 38 | --------------------------------------------------------------------------------