terrain 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: d37822d50c6e00a4b0cfb6720ef147eef776554c
4
- data.tar.gz: fa979a9c65f39f0f36101126c8c4ffab1ee583ea
3
+ metadata.gz: 35b20c85da62fa29911c7f66fd6c3152dd138d21
4
+ data.tar.gz: 16e76c904d30e761b3667f0ae0c135fad8b7529c
5
5
  SHA512:
6
- metadata.gz: b783ca61aaf45f49ffe57c99a110dccdaba51bdf562edaf678c1eb3069d84ebd4ad12cac36a71d400afa071d7c26f4e491153bcc2beccb11e0ca8ea2ee616b45
7
- data.tar.gz: 559f5adc616c79de489abe36483534c5b9c437d3939f6f45145e4543e4ff8bcc9408ffc7d3c0751a37187988aa917ec0a9d9bdd45bf778b7653c8a798d0f207f
6
+ metadata.gz: 42432a6d37702385300c453a526620951a797acfaeaece1f17b81546fb9b041921fb1ae5f452bd37892d5bccb2d6ec71a0037a811c250896f9e28558705f9fe2
7
+ data.tar.gz: 89b74868b62563bec9d303e926632559ee79a4d65d5759020d650b6cb9d35ca70e085f05d4d186017f94d457d9dad9956797bede36615216277ba59b3f7f8251
data/.gitignore CHANGED
@@ -1 +1,2 @@
1
1
  Gemfile.lock
2
+ *.gem
data/.travis.yml ADDED
@@ -0,0 +1,6 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.0.0
4
+ - 2.1.0
5
+ - 2.2.3
6
+ - 2.3.0
data/Gemfile CHANGED
@@ -6,5 +6,6 @@ gem 'airborne'
6
6
  gem 'factory_girl'
7
7
  gem 'faker'
8
8
  gem 'rails'
9
+ gem 'rake'
9
10
  gem 'rspec-rails'
10
11
  gem 'sqlite3'
data/README.md CHANGED
@@ -1,11 +1,8 @@
1
1
  # Terrain
2
2
 
3
- Opinionated toolkit for building CRUD APIs with Rails
3
+ [![Build Status](https://travis-ci.org/scttnlsn/terrain.png?branch=master)](https://travis-ci.org/scttnlsn/terrain)
4
4
 
5
- * error handling
6
- * basic CRUD
7
- * serialization via
8
- * authorization via [Pundit](https://github.com/elabs/pundit)
5
+ Opinionated toolkit for building CRUD APIs with Rails
9
6
 
10
7
  ## Install
11
8
 
@@ -17,6 +14,18 @@ gem 'terrain'
17
14
 
18
15
  ## Usage
19
16
 
17
+ * [Error handling](#error-handling)
18
+ * [Resources](#resources)
19
+ * [Authorization](#authorization)
20
+ * [Serialization](#serialization)
21
+ * [Querying](#querying)
22
+ * [Filtering](#filtering)
23
+ * [Ordering](#ordering)
24
+ * [Pagination](#pagination)
25
+ * [Relationships](#relationships)
26
+ * [CRUD operations](#crud-operations)
27
+ * [Config](#config)
28
+
20
29
  ### Error handling
21
30
 
22
31
  ```ruby
@@ -39,7 +48,8 @@ JSON responses are of the form:
39
48
  {
40
49
  "error": {
41
50
  "key": "type_of_error",
42
- "message": "Localized error message"
51
+ "message": "Localized error message",
52
+ "details": "Optional details"
43
53
  }
44
54
  }
45
55
  ```
@@ -55,7 +65,7 @@ class ExampleController < ApplicationController
55
65
  private
56
66
 
57
67
  def my_error
58
- error_response(:type_of_error, 500)
68
+ error_response(:type_of_error, 500, { some: :details })
59
69
  end
60
70
  end
61
71
  ```
@@ -84,11 +94,75 @@ via [ActiveModelSerializers](https://github.com/rails-api/active_model_serialize
84
94
 
85
95
  #### Querying
86
96
 
87
- * `include` - This corresponds to the `ActiveModelSerializers` include option and embeds the given relationships in the response. Relationships are also preloaded according to the given string. If omitted then no relationships will be included or embedded in the response.
97
+ Records of a given resource are queried by requesting the `index` action.
98
+
99
+ ##### Filtering
100
+
101
+ Queries are scoped to the results returned from the `resource_scope` method. By default this returns all records, however, you can override it to further filter the results (i.e. based on query params, nested route params, etc.):
102
+
103
+ ```ruby
104
+ class ExampleController < ApplicationController
105
+ include Terrain::Resource
106
+
107
+ resource Example, permit: [:foo, :bar, :baz]
108
+
109
+ private
110
+
111
+ def resource_scope
112
+ scope = super
113
+ scope = scope.where(foo: params[:foo]) if params[:foo].present?
114
+ scope
115
+ end
116
+ end
117
+ ```
118
+
119
+ ##### Ordering
120
+
121
+ You can pass an `order` param to reorder the response records. Specify a comma-separated list of fields and prefix the field with a `-` for descending order:
122
+
123
+ ```ruby
124
+ # corresponds to Example.order('foo', 'bar desc')
125
+ get :index, order: 'foo,-bar'
126
+ ```
127
+
128
+ ##### Pagination
129
+
130
+ To request a range of records, specify the range in an HTTP header:
131
+
132
+ ```ruby
133
+ # Request the first 10 records
134
+ get :index, {}, { 'Range' => '0-9' }
135
+ ```
136
+
137
+ All responses include a `Content-Range` header that specifies the exact range returned as well as a total count of records. i.e.
138
+
139
+ ```
140
+ Content-Range: 0-9/100
141
+ ```
142
+
143
+ You can also pass open ended ranges such as `10-` (i.e. skip the first 10 records).
144
+
145
+ ##### Relationships
146
+
147
+ No model relationships are serialized in the response by default. To specify the set of relationships to be embedded in the response, pass a comma-separated list of relationships in the `include` param.
148
+
149
+ As an example, suppose we're querying for posts which each have many tags and belong to an author. We could embed those relationships with the following `include` param:
150
+
151
+ ```ruby
152
+ get :index, include: 'author,tags'
153
+ ```
154
+
155
+ Suppose now that the author also has a profile relationship. We could include the author, author profile and tags by passing:
156
+
157
+ ```ruby
158
+ get :index, include: 'author.profile,tags'
159
+ ```
160
+
161
+ Included relationships are automatically preloaded via the ActiveRecord `includes` method. The `include` param is also supported in `show` actions.
88
162
 
89
163
  #### CRUD operations
90
164
 
91
- You may need an action to perform additional steps beyond simple persistence. There are hooks for each CRUD operation (shown below with their default implementation):
165
+ You may need an action to perform additional steps beyond simple persistence. There are methods for performing CRUD operations that can be overridden (shown below with their default implementation):
92
166
 
93
167
  ```ruby
94
168
  class ExampleController < ApplicationController
@@ -112,3 +186,12 @@ class ExampleController < ApplicationController
112
186
  end
113
187
  end
114
188
  ```
189
+
190
+ ### Config
191
+
192
+ ```ruby
193
+ Terrain.configure do |config|
194
+ # Maximum number of records returned
195
+ config.max_records = Float::INFINITY
196
+ end
197
+ ```
data/Rakefile ADDED
@@ -0,0 +1,5 @@
1
+ require 'rspec/core/rake_task'
2
+
3
+ RSpec::Core::RakeTask.new(:spec)
4
+
5
+ task default: :spec
@@ -0,0 +1,9 @@
1
+ module Terrain
2
+ class Config
3
+ attr_accessor :max_records
4
+
5
+ def max_records
6
+ @max_records || Float::INFINITY
7
+ end
8
+ end
9
+ end
@@ -1,3 +1,5 @@
1
+ require 'terrain/page'
2
+
1
3
  module Terrain
2
4
  module Errors
3
5
  extend ActiveSupport::Concern
@@ -9,8 +11,25 @@ module Terrain
9
11
  rescue_from 'ActionController::RoutingError', with: :route_not_found
10
12
  rescue_from 'ActiveRecord::RecordInvalid', with: :record_invalid
11
13
 
14
+ rescue_from Terrain::Page::RangeError, with: :range_error
15
+
12
16
  private
13
17
 
18
+ def error_response(key = :server_error, status = 500, details = nil)
19
+ result = {
20
+ error: {
21
+ key: key,
22
+ message: I18n.t("terrain.errors.#{key}", request: request)
23
+ }
24
+ }
25
+
26
+ if details.present?
27
+ result[:error][:details] = details
28
+ end
29
+
30
+ render json: result, status: status
31
+ end
32
+
14
33
  def association_not_found
15
34
  error_response(:association_not_found, 400)
16
35
  end
@@ -31,19 +50,12 @@ module Terrain
31
50
  error_response(:route_not_found, 404)
32
51
  end
33
52
 
34
- def record_invalid
35
- error_response(:record_invalid, 422)
53
+ def record_invalid(error)
54
+ error_response(:record_invalid, 422, error.record.errors.to_hash)
36
55
  end
37
56
 
38
- def error_response(key = :server_error, status = 500)
39
- result = {
40
- error: {
41
- key: key,
42
- message: I18n.t("terrain.errors.#{key}", request: request)
43
- }
44
- }
45
-
46
- render json: result, status: status
57
+ def range_error
58
+ error_response(:range_error, 416)
47
59
  end
48
60
  end
49
61
  end
@@ -0,0 +1,63 @@
1
+ module Terrain
2
+ class Page
3
+ class RangeError < StandardError; end
4
+
5
+ RANGE_REGEX = /^(?<from>[0-9]*)-(?<to>[0-9]*)$/
6
+
7
+ attr_reader :scope, :range
8
+
9
+ def initialize(scope, range = nil)
10
+ @scope = scope
11
+ @range = range
12
+ end
13
+
14
+ def bounds
15
+ @bounds ||= begin
16
+ if range.present?
17
+ if match
18
+ raise RangeError if from > to
19
+ [from, to]
20
+ else
21
+ raise RangeError
22
+ end
23
+ else
24
+ [0, count - 1]
25
+ end
26
+ end
27
+ end
28
+
29
+ def count
30
+ @count ||= scope.count
31
+ end
32
+
33
+ def records
34
+ from, to = bounds
35
+ limit = [to - from + 1, Terrain.config.max_records].min
36
+ @records ||= scope.offset(from).limit(limit)
37
+ end
38
+
39
+ def content_range
40
+ if count > 0
41
+ from, to = bounds
42
+ to = [to, from + records.count - 1].min
43
+ "#{from}-#{to}/#{count}"
44
+ else
45
+ '*/0'
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def match
52
+ range.match(RANGE_REGEX)
53
+ end
54
+
55
+ def from
56
+ match && match[:from].present? ? match[:from].to_i : 0
57
+ end
58
+
59
+ def to
60
+ match && match[:to].present? ? match[:to].to_i : (count - 1)
61
+ end
62
+ end
63
+ end
@@ -1,6 +1,8 @@
1
1
  require 'active_model_serializers'
2
2
  require 'pundit'
3
3
 
4
+ require 'terrain/page'
5
+
4
6
  module Terrain
5
7
  module Resource
6
8
  extend ActiveSupport::Concern
@@ -19,7 +21,13 @@ module Terrain
19
21
 
20
22
  module Actions
21
23
  def index
22
- render json: preloaded_resource.all
24
+ scope = order(resource_scope)
25
+
26
+ range = request.headers['Range']
27
+ page = Terrain::Page.new(scope, range)
28
+
29
+ headers['Content-Range'] = page.content_range
30
+ render json: page.records, include: (params[:include] || [])
23
31
  end
24
32
 
25
33
  def create
@@ -88,6 +96,29 @@ module Terrain
88
96
  end
89
97
  end
90
98
 
99
+ def order(scope)
100
+ if params[:order].present?
101
+ order = params[:order].gsub(/ /, '').split(',').map do |field|
102
+ direction = 'asc'
103
+
104
+ if field[0] == '-'
105
+ direction = 'desc'
106
+ field = field[1..-1]
107
+ end
108
+
109
+ "#{field} #{direction}"
110
+ end
111
+
112
+ scope = scope.order(order)
113
+ end
114
+
115
+ scope
116
+ end
117
+
118
+ def resource_scope
119
+ preloaded_resource.all
120
+ end
121
+
91
122
  def load_record
92
123
  preloaded_resource.find(params[:id])
93
124
  end
data/lib/terrain.rb CHANGED
@@ -1,4 +1,17 @@
1
1
  require 'active_support'
2
2
 
3
+ require 'terrain/config'
3
4
  require 'terrain/errors'
4
5
  require 'terrain/resource'
6
+
7
+ module Terrain
8
+ extend self
9
+
10
+ def config
11
+ @config ||= Config.new
12
+ end
13
+
14
+ def configure
15
+ yield config
16
+ end
17
+ end
data/spec/spec_helper.rb CHANGED
@@ -43,7 +43,7 @@ module Helpers
43
43
  def serialize(value, options = {})
44
44
  options[:include] ||= []
45
45
  if value.respond_to?(:each)
46
- ActiveModel::Serializer::CollectionSerializer.new(value, options).as_json
46
+ value.map { |item| serialize(item, options) }
47
47
  else
48
48
  ActiveModelSerializers::SerializableResource.new(value, options).as_json.symbolize_keys
49
49
  end
@@ -59,16 +59,36 @@ describe 'Terrain::Errors', type: :controller do
59
59
  it { expect_json('error.key', 'route_not_found') }
60
60
  end
61
61
 
62
- context 'route not found' do
62
+ context 'range error' do
63
+ controller do
64
+ def index
65
+ raise Terrain::Page::RangeError
66
+ end
67
+ end
68
+
69
+ it { expect(response.status).to eq 416 }
70
+ it { expect_json_types(error: :object) }
71
+ it { expect_json_types('error.message', :string) }
72
+ it { expect_json('error.key', 'range_error') }
73
+ end
74
+
75
+ context 'record invalid' do
76
+ let(:record) { Example.new }
77
+
63
78
  controller do
64
79
  def index
65
- raise ActiveRecord::RecordInvalid, Example.new
80
+ record = Example.new
81
+ record.valid?
82
+ raise ActiveRecord::RecordInvalid, record
66
83
  end
67
84
  end
68
85
 
86
+ before { record.valid? }
87
+
69
88
  it { expect(response.status).to eq 422 }
70
89
  it { expect_json_types(error: :object) }
71
90
  it { expect_json_types('error.message', :string) }
72
91
  it { expect_json('error.key', 'record_invalid') }
92
+ it { expect_json('error.details', record.errors.to_hash) }
73
93
  end
74
94
  end
@@ -0,0 +1,101 @@
1
+ require 'spec_helper'
2
+
3
+ describe Terrain::Page do
4
+ let!(:records) { create_list(:example, 10) }
5
+ let(:scope) { Example.all }
6
+ let(:empty_scope) { Example.where('1 = 0') }
7
+ let(:range) { '0-4' }
8
+ let(:page) { described_class.new(scope, range) }
9
+
10
+ describe '#bounds' do
11
+ subject { page.bounds }
12
+
13
+ it { is_expected.to eq [0, 4] }
14
+
15
+ context 'with empty range' do
16
+ let(:range) { '5-5' }
17
+
18
+ it { is_expected.to eq [5, 5] }
19
+ end
20
+
21
+ context 'with no upper bound' do
22
+ let(:range) { '5-' }
23
+
24
+ it { is_expected.to eq [5, 9] }
25
+ end
26
+
27
+ context 'with no lower bound' do
28
+ let(:range) { '-5' }
29
+
30
+ it { is_expected.to eq [0, 5] }
31
+ end
32
+
33
+ context 'with invalid range' do
34
+ let(:range) { '3-2' }
35
+
36
+ it { expect { subject }.to raise_error described_class::RangeError }
37
+ end
38
+
39
+ context 'with invalid upper bound' do
40
+ let(:range) { '5-x' }
41
+
42
+ it { expect { subject }.to raise_error described_class::RangeError }
43
+ end
44
+ end
45
+
46
+ describe '#count' do
47
+ subject { page.count }
48
+
49
+ it { is_expected.to eq 10 }
50
+
51
+ context 'with no records' do
52
+ let(:scope) { empty_scope }
53
+
54
+ it { is_expected.to eq 0 }
55
+ end
56
+ end
57
+
58
+ describe '#records' do
59
+ subject { page.records }
60
+
61
+ it { is_expected.to eq records[0..4] }
62
+
63
+ context 'with fewer records than requested range' do
64
+ let(:range) { '5-14' }
65
+
66
+ it { is_expected.to eq records[5..9] }
67
+ end
68
+
69
+ context 'with max records configured' do
70
+ before { Terrain.config.max_records = 3 }
71
+ after { Terrain.config.max_records = nil }
72
+
73
+ it { is_expected.to eq records[0..2] }
74
+ end
75
+ end
76
+
77
+ describe '#content_range' do
78
+ subject { page.content_range }
79
+
80
+ it { is_expected.to eq '0-4/10' }
81
+
82
+ context 'with fewer records than requested range' do
83
+ let(:range) { '5-14' }
84
+
85
+ it { is_expected.to eq '5-9/10' }
86
+ end
87
+
88
+ context 'with max records configured' do
89
+ before { Terrain.config.max_records = 3 }
90
+ after { Terrain.config.max_records = nil }
91
+
92
+ it { is_expected.to eq '0-2/10' }
93
+ end
94
+
95
+ context 'with no records' do
96
+ let(:scope) { empty_scope }
97
+
98
+ it { is_expected.to eq '*/0' }
99
+ end
100
+ end
101
+ end
@@ -8,7 +8,7 @@ describe 'Terrain::Resource', type: :controller do
8
8
  end
9
9
 
10
10
  describe '#index' do
11
- let!(:examples) { create_list(:example, 10) }
11
+ let!(:records) { create_list(:example, 10) }
12
12
 
13
13
  it 'responds with 200 status' do
14
14
  get :index
@@ -17,7 +17,105 @@ describe 'Terrain::Resource', type: :controller do
17
17
 
18
18
  it 'responds with serialized records' do
19
19
  get :index
20
- expect(response.body).to eq serialize(Example.all).to_json
20
+ expect(response.body).to eq serialize(records).to_json
21
+ end
22
+
23
+ it 'responds with Content-Range header' do
24
+ get :index
25
+ expect(response.headers['Content-Range']).to eq Terrain::Page.new(Example.all).content_range
26
+ end
27
+
28
+ context 'filtered' do
29
+ let(:record) { records.first }
30
+
31
+ before do
32
+ record.foo = 'test'
33
+ record.save!
34
+ end
35
+
36
+ controller do
37
+ resource Example
38
+
39
+ def resource_scope
40
+ super.where(foo: params[:foo])
41
+ end
42
+ end
43
+
44
+ it 'responds with filtered records' do
45
+ get :index, foo: 'test'
46
+ expect(response.body).to eq serialize([record]).to_json
47
+ end
48
+ end
49
+
50
+ context 'ordered' do
51
+ let(:params) { ActionController::Parameters.new(order: 'foo') }
52
+
53
+ it 'responds with ordered records' do
54
+ get :index, params
55
+ expect(response.body).to eq serialize(Example.order('foo asc')).to_json
56
+ end
57
+
58
+ context 'descending' do
59
+ let(:params) { ActionController::Parameters.new(order: '-foo') }
60
+
61
+ it 'responds with ordered records' do
62
+ get :index, params
63
+ expect(response.body).to eq serialize(Example.order('foo desc')).to_json
64
+ end
65
+ end
66
+
67
+ context 'multiple sort columns' do
68
+ let(:params) { ActionController::Parameters.new(order: ' - foo , bar ') }
69
+
70
+ it 'responds with ordered records' do
71
+ get :index, params
72
+ expect(response.body).to eq serialize(Example.order('foo desc', 'bar asc')).to_json
73
+ end
74
+ end
75
+ end
76
+
77
+ context 'paged' do
78
+ before { request.headers['Range'] = '0-4' }
79
+
80
+ it 'responds with requested records' do
81
+ get :index
82
+ expect(response.body).to eq serialize(records[0..4]).to_json
83
+ end
84
+
85
+ it 'responds with Content-Range header' do
86
+ get :index
87
+ expect(response.headers['Content-Range']).to eq Terrain::Page.new(Example.all, '0-4').content_range
88
+ end
89
+ end
90
+
91
+ context 'with relations' do
92
+ before do
93
+ records.each do |record|
94
+ create_list(:widget, 3, example: record)
95
+ end
96
+ end
97
+
98
+ it 'does not include relations in serialized record' do
99
+ get :index
100
+ expect(response.body).to eq serialize(records, include: []).to_json
101
+ end
102
+
103
+ context 'with valid include' do
104
+ let(:params) { ActionController::Parameters.new(include: 'widgets') }
105
+
106
+ it 'includes relations in serialized record' do
107
+ get :index, params
108
+ expect(response.body).to eq serialize(records, include: ['widgets']).to_json
109
+ end
110
+ end
111
+
112
+ context 'with invalid include' do
113
+ let(:params) { ActionController::Parameters.new(include: 'widgets,wrong') }
114
+
115
+ it 'raises error' do
116
+ expect { get :index, params }.to raise_error ActiveRecord::AssociationNotFoundError
117
+ end
118
+ end
21
119
  end
22
120
  end
23
121
 
data/terrain.gemspec CHANGED
@@ -3,7 +3,7 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
3
 
4
4
  Gem::Specification.new do |gem|
5
5
  gem.name = 'terrain'
6
- gem.version = '0.0.1'
6
+ gem.version = '0.0.2'
7
7
  gem.summary = 'Terrain'
8
8
  gem.description = ''
9
9
  gem.license = 'MIT'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: terrain
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Scott Nelson
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-05-04 00:00:00.000000000 Z
11
+ date: 2016-05-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -60,10 +60,14 @@ extra_rdoc_files: []
60
60
  files:
61
61
  - ".gitignore"
62
62
  - ".rspec"
63
+ - ".travis.yml"
63
64
  - Gemfile
64
65
  - README.md
66
+ - Rakefile
65
67
  - lib/terrain.rb
68
+ - lib/terrain/config.rb
66
69
  - lib/terrain/errors.rb
70
+ - lib/terrain/page.rb
67
71
  - lib/terrain/resource.rb
68
72
  - spec/spec_helper.rb
69
73
  - spec/support/example.rb
@@ -73,6 +77,7 @@ files:
73
77
  - spec/support/widget.rb
74
78
  - spec/support/widget_factory.rb
75
79
  - spec/terrain/errors_spec.rb
80
+ - spec/terrain/page_spec.rb
76
81
  - spec/terrain/resource_spec.rb
77
82
  - terrain.gemspec
78
83
  homepage: https://github.com/scttnlsn/terrain
@@ -108,4 +113,5 @@ test_files:
108
113
  - spec/support/widget.rb
109
114
  - spec/support/widget_factory.rb
110
115
  - spec/terrain/errors_spec.rb
116
+ - spec/terrain/page_spec.rb
111
117
  - spec/terrain/resource_spec.rb