jsonapi-resources 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +20 -0
- data/Gemfile +22 -0
- data/LICENSE.txt +22 -0
- data/README.md +451 -0
- data/Rakefile +24 -0
- data/jsonapi-resources.gemspec +29 -0
- data/lib/jsonapi-resources.rb +2 -0
- data/lib/jsonapi/active_record_operations_processor.rb +17 -0
- data/lib/jsonapi/association.rb +45 -0
- data/lib/jsonapi/error.rb +17 -0
- data/lib/jsonapi/error_codes.rb +16 -0
- data/lib/jsonapi/exceptions.rb +177 -0
- data/lib/jsonapi/operation.rb +151 -0
- data/lib/jsonapi/operation_result.rb +15 -0
- data/lib/jsonapi/operations_processor.rb +47 -0
- data/lib/jsonapi/request.rb +254 -0
- data/lib/jsonapi/resource.rb +417 -0
- data/lib/jsonapi/resource_controller.rb +169 -0
- data/lib/jsonapi/resource_for.rb +25 -0
- data/lib/jsonapi/resource_serializer.rb +209 -0
- data/lib/jsonapi/resources/version.rb +5 -0
- data/lib/jsonapi/routing_ext.rb +104 -0
- data/test/config/database.yml +5 -0
- data/test/controllers/controller_test.rb +940 -0
- data/test/fixtures/active_record.rb +585 -0
- data/test/helpers/functional_helpers.rb +59 -0
- data/test/helpers/hash_helpers.rb +13 -0
- data/test/helpers/value_matchers.rb +60 -0
- data/test/helpers/value_matchers_test.rb +40 -0
- data/test/integration/requests/request_test.rb +39 -0
- data/test/integration/routes/routes_test.rb +85 -0
- data/test/test_helper.rb +98 -0
- data/test/unit/operation/operations_processor_test.rb +188 -0
- data/test/unit/resource/resource_test.rb +45 -0
- data/test/unit/serializer/serializer_test.rb +429 -0
- metadata +193 -0
data/Rakefile
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
#!/usr/bin/env rake
|
2
|
+
require "bundler/gem_tasks"
|
3
|
+
require "rake/testtask"
|
4
|
+
|
5
|
+
desc 'Run tests'
|
6
|
+
test_task = Rake::TestTask.new(:test) do |t|
|
7
|
+
t.libs << 'test'
|
8
|
+
t.pattern = 'test/**/*_test.rb'
|
9
|
+
t.verbose = true
|
10
|
+
end
|
11
|
+
|
12
|
+
task default: :test
|
13
|
+
|
14
|
+
desc 'Run tests in isolated processes'
|
15
|
+
namespace :test do
|
16
|
+
task :isolated do
|
17
|
+
Dir[test_task.pattern].each do |file|
|
18
|
+
cmd = ['ruby']
|
19
|
+
test_task.libs.each { |l| cmd << '-I' << l }
|
20
|
+
cmd << file
|
21
|
+
sh cmd.join(' ')
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'jsonapi/resources/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'jsonapi-resources'
|
8
|
+
spec.version = JSONAPI::Resources::VERSION
|
9
|
+
spec.authors = ['Dan Gebhardt', 'Larry Gebhardt']
|
10
|
+
spec.email = ['dan@cerebris.com', 'larry@cerebris.com']
|
11
|
+
spec.summary = %q{Easily support JSON API in Rails.}
|
12
|
+
spec.description = %q{A resource-centric approach to implementing the controllers, routes, and serializers needed to support the JSON API spec.}
|
13
|
+
spec.homepage = 'https://github.com/cerebris/jsonapi-resources'
|
14
|
+
spec.license = 'MIT'
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0")
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ['lib']
|
20
|
+
spec.required_ruby_version = '>= 1.9.3'
|
21
|
+
|
22
|
+
spec.add_development_dependency 'bundler', '~> 1.5'
|
23
|
+
spec.add_development_dependency 'rake'
|
24
|
+
spec.add_development_dependency 'minitest'
|
25
|
+
spec.add_development_dependency 'minitest-spec-rails'
|
26
|
+
spec.add_development_dependency 'minitest-reporters'
|
27
|
+
spec.add_development_dependency 'simplecov'
|
28
|
+
spec.add_development_dependency 'rails', '>= 4.0'
|
29
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
require 'jsonapi/operations_processor'
|
2
|
+
|
3
|
+
module JSONAPI
|
4
|
+
class ActiveRecordOperationsProcessor < OperationsProcessor
|
5
|
+
|
6
|
+
private
|
7
|
+
def transaction
|
8
|
+
ActiveRecord::Base.transaction do
|
9
|
+
yield
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def rollback
|
14
|
+
raise ActiveRecord::Rollback
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module JSONAPI
|
2
|
+
class Association
|
3
|
+
def initialize(name, options={})
|
4
|
+
@name = name.to_s
|
5
|
+
@options = options
|
6
|
+
@key = options[:key] ? options[:key].to_sym : nil
|
7
|
+
@primary_key = options.fetch(:primary_key, 'id').to_sym
|
8
|
+
@treat_as_set = options.fetch(:treat_as_set, false) == true
|
9
|
+
end
|
10
|
+
|
11
|
+
def key
|
12
|
+
@key
|
13
|
+
end
|
14
|
+
|
15
|
+
def primary_key
|
16
|
+
@primary_key
|
17
|
+
end
|
18
|
+
|
19
|
+
def treat_as_set
|
20
|
+
@treat_as_set
|
21
|
+
end
|
22
|
+
|
23
|
+
def serialize_type_name
|
24
|
+
@serialize_type_name
|
25
|
+
end
|
26
|
+
|
27
|
+
class HasOne < Association
|
28
|
+
def initialize(name, options={})
|
29
|
+
super
|
30
|
+
class_name = options.fetch(:class_name, name.to_s.capitalize)
|
31
|
+
@serialize_type_name = class_name.underscore.pluralize.to_sym
|
32
|
+
@key ||= "#{name}_id".to_sym
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
class HasMany < Association
|
37
|
+
def initialize(name, options={})
|
38
|
+
super
|
39
|
+
class_name = options.fetch(:class_name, name.to_s.capitalize.singularize).to_sym
|
40
|
+
@serialize_type_name = class_name.to_s.underscore.pluralize.to_sym
|
41
|
+
@key ||= "#{name.to_s.singularize}_ids".to_sym
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module JSONAPI
|
2
|
+
class Error
|
3
|
+
|
4
|
+
attr_accessor :title, :detail, :id, :href, :code, :path, :links, :status
|
5
|
+
|
6
|
+
def initialize(options={})
|
7
|
+
@title = options[:title]
|
8
|
+
@detail = options[:detail]
|
9
|
+
@id = options[:id]
|
10
|
+
@href = options[:href]
|
11
|
+
@code = options[:code]
|
12
|
+
@path = options[:path]
|
13
|
+
@links = options[:links]
|
14
|
+
@status = options[:status]
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module JSONAPI
|
2
|
+
VALIDATION_ERROR = 100
|
3
|
+
INVALID_RESOURCE = 101
|
4
|
+
FILTER_NOT_ALLOWED = 102
|
5
|
+
INVALID_FIELD_VALUE = 103
|
6
|
+
INVALID_FIELD = 104
|
7
|
+
PARAM_NOT_ALLOWED = 105
|
8
|
+
PARAM_MISSING = 106
|
9
|
+
INVALID_FILTER_VALUE = 107
|
10
|
+
COUNT_MISMATCH = 108
|
11
|
+
KEY_ORDER_MISMATCH = 109
|
12
|
+
KEY_NOT_INCLUDED_IN_URL = 110
|
13
|
+
|
14
|
+
RECORD_NOT_FOUND = 404
|
15
|
+
LOCKED = 423
|
16
|
+
end
|
@@ -0,0 +1,177 @@
|
|
1
|
+
module JSONAPI
|
2
|
+
module Exceptions
|
3
|
+
class Error < RuntimeError; end
|
4
|
+
|
5
|
+
class InvalidResource < Error
|
6
|
+
attr_accessor :resource
|
7
|
+
def initialize(resource)
|
8
|
+
@resource = resource
|
9
|
+
end
|
10
|
+
|
11
|
+
def errors
|
12
|
+
[JSONAPI::Error.new(code: JSONAPI::INVALID_RESOURCE,
|
13
|
+
status: :bad_request,
|
14
|
+
title: 'Invalid resource',
|
15
|
+
detail: "#{resource} is not a valid resource.")]
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
class RecordNotFound < Error
|
20
|
+
attr_accessor :id
|
21
|
+
def initialize(id)
|
22
|
+
@id = id
|
23
|
+
end
|
24
|
+
|
25
|
+
def errors
|
26
|
+
[JSONAPI::Error.new(code: JSONAPI::RECORD_NOT_FOUND,
|
27
|
+
status: :not_found,
|
28
|
+
title: 'Record not found',
|
29
|
+
detail: "The record identified by #{id} could not be found.")]
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
class FilterNotAllowed < Error
|
34
|
+
attr_accessor :filter
|
35
|
+
def initialize(filter)
|
36
|
+
@filter = filter
|
37
|
+
end
|
38
|
+
|
39
|
+
def errors
|
40
|
+
[JSONAPI::Error.new(code: JSONAPI::FILTER_NOT_ALLOWED,
|
41
|
+
status: :bad_request,
|
42
|
+
title: 'Filter not allowed',
|
43
|
+
detail: "#{filter} is not allowed.")]
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
class InvalidFilterValue < Error
|
48
|
+
attr_accessor :filter, :value
|
49
|
+
def initialize(filter, value)
|
50
|
+
@filter = filter
|
51
|
+
@value = value
|
52
|
+
end
|
53
|
+
|
54
|
+
def errors
|
55
|
+
[JSONAPI::Error.new(code: JSONAPI::INVALID_FILTER_VALUE,
|
56
|
+
status: :bad_request,
|
57
|
+
title: 'Invalid filter value',
|
58
|
+
detail: "#{value} is not a valid value for #{filter}.")]
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
class InvalidFieldValue < Error
|
63
|
+
attr_accessor :field, :value
|
64
|
+
def initialize(field, value)
|
65
|
+
@field = field
|
66
|
+
@value = value
|
67
|
+
end
|
68
|
+
|
69
|
+
def errors
|
70
|
+
[JSONAPI::Error.new(code: JSONAPI::INVALID_FIELD_VALUE,
|
71
|
+
status: :bad_request,
|
72
|
+
title: 'Invalid field value',
|
73
|
+
detail: "#{value} is not a valid value for #{field}.")]
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
class InvalidField < Error
|
78
|
+
attr_accessor :field, :type
|
79
|
+
def initialize(type, field)
|
80
|
+
@field = field
|
81
|
+
@type = type
|
82
|
+
end
|
83
|
+
|
84
|
+
def errors
|
85
|
+
[JSONAPI::Error.new(code: JSONAPI::INVALID_FIELD,
|
86
|
+
status: :bad_request,
|
87
|
+
title: 'Invalid field',
|
88
|
+
detail: "#{field} is not a valid field for #{type}.")]
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
class ParametersNotAllowed < Error
|
93
|
+
attr_accessor :params
|
94
|
+
def initialize(params)
|
95
|
+
@params = params
|
96
|
+
end
|
97
|
+
|
98
|
+
def errors
|
99
|
+
params.collect { |param|
|
100
|
+
JSONAPI::Error.new(code: JSONAPI::PARAM_NOT_ALLOWED,
|
101
|
+
status: :bad_request,
|
102
|
+
title: 'Param not allowed',
|
103
|
+
detail: "#{param} is not allowed.")
|
104
|
+
}
|
105
|
+
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
class ParameterMissing < Error
|
110
|
+
attr_accessor :param
|
111
|
+
def initialize(param)
|
112
|
+
@param = param
|
113
|
+
end
|
114
|
+
|
115
|
+
def errors
|
116
|
+
[JSONAPI::Error.new(code: JSONAPI::PARAM_MISSING,
|
117
|
+
status: :bad_request,
|
118
|
+
title: 'Missing Parameter',
|
119
|
+
detail: "The required parameter, #{param}, is missing.")]
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
class CountMismatch < Error
|
124
|
+
def errors
|
125
|
+
[JSONAPI::Error.new(code: JSONAPI::COUNT_MISMATCH,
|
126
|
+
status: :bad_request,
|
127
|
+
title: 'Count to key mismatch',
|
128
|
+
detail: 'The resource collection does not contain the same number of objects as the number of keys.')]
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
class KeyNotIncludedInURL < Error
|
133
|
+
attr_accessor :key
|
134
|
+
def initialize(key)
|
135
|
+
@key = key
|
136
|
+
end
|
137
|
+
|
138
|
+
def errors
|
139
|
+
[JSONAPI::Error.new(code: JSONAPI::KEY_NOT_INCLUDED_IN_URL,
|
140
|
+
status: :bad_request,
|
141
|
+
title: 'Key is not included in URL',
|
142
|
+
detail: "The URL does not support the key #{key}")]
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
class MissingKey < Error
|
147
|
+
def errors
|
148
|
+
[JSONAPI::Error.new(code: JSONAPI::KEY_ORDER_MISMATCH,
|
149
|
+
status: :bad_request,
|
150
|
+
title: 'A key is required',
|
151
|
+
detail: 'The resource object does not contain a key.')]
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
class RecordLocked < Error
|
156
|
+
attr_accessor :message
|
157
|
+
def initialize(message)
|
158
|
+
@message = message
|
159
|
+
end
|
160
|
+
|
161
|
+
def errors
|
162
|
+
[JSONAPI::Error.new(code: JSONAPI::LOCKED,
|
163
|
+
status: :locked,
|
164
|
+
title: 'Locked resource',
|
165
|
+
detail: "#{message}")]
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
class ValidationErrors < Error
|
170
|
+
attr_accessor :errors
|
171
|
+
def initialize(errors)
|
172
|
+
@errors = errors
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
end
|
177
|
+
end
|
@@ -0,0 +1,151 @@
|
|
1
|
+
module JSONAPI
|
2
|
+
class Operation
|
3
|
+
|
4
|
+
attr_reader :resource_klass
|
5
|
+
|
6
|
+
def initialize(resource_klass)
|
7
|
+
@resource_klass = resource_klass
|
8
|
+
end
|
9
|
+
|
10
|
+
def apply(context)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
class CreateResourceOperation < Operation
|
15
|
+
attr_reader :values
|
16
|
+
|
17
|
+
def initialize(resource_klass, values = {})
|
18
|
+
@values = values
|
19
|
+
super(resource_klass)
|
20
|
+
end
|
21
|
+
|
22
|
+
def apply(context)
|
23
|
+
resource = @resource_klass.new
|
24
|
+
resource.replace_fields(@values, context)
|
25
|
+
resource.save
|
26
|
+
|
27
|
+
return JSONAPI::OperationResult.new(:created, resource)
|
28
|
+
|
29
|
+
rescue JSONAPI::Exceptions::Error => e
|
30
|
+
return JSONAPI::OperationResult.new(e.errors.count == 1 ? e.errors[0].code : :bad_request, nil, e.errors)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
class RemoveResourceOperation < Operation
|
35
|
+
attr_reader :resource_id
|
36
|
+
def initialize(resource_klass, resource_id)
|
37
|
+
@resource_id = resource_id
|
38
|
+
super(resource_klass)
|
39
|
+
end
|
40
|
+
|
41
|
+
def apply(context)
|
42
|
+
resource = @resource_klass.find_by_key(@resource_id, context)
|
43
|
+
resource.remove(context)
|
44
|
+
|
45
|
+
return JSONAPI::OperationResult.new(:no_content)
|
46
|
+
|
47
|
+
rescue ActiveRecord::DeleteRestrictionError => e
|
48
|
+
record_locked_error = JSONAPI::Exceptions::RecordLocked.new(e.message)
|
49
|
+
return JSONAPI::OperationResult.new(record_locked_error.errors[0].code, nil, record_locked_error.errors)
|
50
|
+
rescue JSONAPI::Exceptions::Error => e
|
51
|
+
return JSONAPI::OperationResult.new(e.errors.count == 1 ? e.errors[0].code : :bad_request, nil, e.errors)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
class ReplaceFieldsOperation < Operation
|
56
|
+
attr_reader :values, :resource_id
|
57
|
+
|
58
|
+
def initialize(resource_klass, resource_id, values)
|
59
|
+
@resource_id = resource_id
|
60
|
+
@values = values
|
61
|
+
super(resource_klass)
|
62
|
+
end
|
63
|
+
|
64
|
+
def apply(context)
|
65
|
+
resource = @resource_klass.find_by_key(@resource_id, context)
|
66
|
+
resource.replace_fields(values, context)
|
67
|
+
resource.save
|
68
|
+
|
69
|
+
return JSONAPI::OperationResult.new(:ok, resource)
|
70
|
+
|
71
|
+
rescue JSONAPI::Exceptions::Error => e
|
72
|
+
return JSONAPI::OperationResult.new(e.errors.count == 1 ? e.errors[0].code : :bad_request, nil, e.errors)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
class ReplaceHasOneAssociationOperation < Operation
|
77
|
+
attr_reader :resource_id, :association_type, :key_value
|
78
|
+
|
79
|
+
def initialize(resource_klass, resource_id, association_type, key_value)
|
80
|
+
@resource_id = resource_id
|
81
|
+
@key_value = key_value
|
82
|
+
@association_type = association_type
|
83
|
+
super(resource_klass)
|
84
|
+
end
|
85
|
+
|
86
|
+
def apply(context)
|
87
|
+
resource = @resource_klass.find_by_key(@resource_id, context)
|
88
|
+
resource.replace_has_one_link(@association_type, @key_value, context)
|
89
|
+
resource.save
|
90
|
+
|
91
|
+
return JSONAPI::OperationResult.new(:created, resource)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
class CreateHasManyAssociationOperation < Operation
|
96
|
+
attr_reader :resource_id, :association_type, :key_values
|
97
|
+
|
98
|
+
def initialize(resource_klass, resource_id, association_type, key_values)
|
99
|
+
@resource_id = resource_id
|
100
|
+
@key_values = key_values
|
101
|
+
@association_type = association_type
|
102
|
+
super(resource_klass)
|
103
|
+
end
|
104
|
+
|
105
|
+
def apply(context)
|
106
|
+
resource = @resource_klass.find_by_key(@resource_id, context)
|
107
|
+
@key_values.each do |value|
|
108
|
+
resource.create_has_many_link(@association_type, value, context)
|
109
|
+
end
|
110
|
+
|
111
|
+
return JSONAPI::OperationResult.new(:created, resource)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
class RemoveHasManyAssociationOperation < Operation
|
116
|
+
attr_reader :resource_id, :association_type, :associated_key
|
117
|
+
|
118
|
+
def initialize(resource_klass, resource_id, association_type, associated_key)
|
119
|
+
@resource_id = resource_id
|
120
|
+
@associated_key = associated_key
|
121
|
+
@association_type = association_type
|
122
|
+
super(resource_klass)
|
123
|
+
end
|
124
|
+
|
125
|
+
def apply(context)
|
126
|
+
resource = @resource_klass.find_by_key(@resource_id, context)
|
127
|
+
resource.remove_has_many_link(@association_type, @associated_key, context)
|
128
|
+
|
129
|
+
return JSONAPI::OperationResult.new(:no_content)
|
130
|
+
end
|
131
|
+
|
132
|
+
end
|
133
|
+
|
134
|
+
class RemoveHasOneAssociationOperation < Operation
|
135
|
+
attr_reader :resource_id, :association_type
|
136
|
+
|
137
|
+
def initialize(resource_klass, resource_id, association_type)
|
138
|
+
@resource_id = resource_id
|
139
|
+
@association_type = association_type
|
140
|
+
super(resource_klass)
|
141
|
+
end
|
142
|
+
|
143
|
+
def apply(context)
|
144
|
+
resource = @resource_klass.find_by_key(@resource_id, context)
|
145
|
+
resource.remove_has_one_link(@association_type, context)
|
146
|
+
resource.save
|
147
|
+
|
148
|
+
return JSONAPI::OperationResult.new(:no_content)
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|