jsonapi-resources 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|