rspec-rails-api 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +14 -0
- data/.gitlab-ci.yml +36 -0
- data/.rspec +3 -0
- data/.rubocop.yml +29 -0
- data/.travis.yml +7 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +457 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/examples/commented.rb +167 -0
- data/lib/rspec/rails/api/dsl/example.rb +97 -0
- data/lib/rspec/rails/api/dsl/example_group.rb +90 -0
- data/lib/rspec/rails/api/entity_config.rb +44 -0
- data/lib/rspec/rails/api/field_config.rb +52 -0
- data/lib/rspec/rails/api/matchers.rb +40 -0
- data/lib/rspec/rails/api/metadata.rb +178 -0
- data/lib/rspec/rails/api/open_api_renderer.rb +215 -0
- data/lib/rspec/rails/api/utils.rb +88 -0
- data/lib/rspec/rails/api/version.rb +9 -0
- data/lib/rspec_rails_api.rb +37 -0
- data/rspec-rails-api.gemspec +37 -0
- metadata +168 -0
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/hash_with_indifferent_access'
|
4
|
+
|
5
|
+
require 'rspec/rails/api/utils'
|
6
|
+
|
7
|
+
# FIXME: Split the matcher in something else; it's too messy.
|
8
|
+
RSpec::Matchers.define :have_many do |expected|
|
9
|
+
match do |actual|
|
10
|
+
@actual = actual
|
11
|
+
@actual = JSON.parse(actual.body) if actual.respond_to? :body
|
12
|
+
|
13
|
+
raise "Response is not an array: #{@actual.class}" unless @actual.is_a? Array
|
14
|
+
raise 'Response has no item to compare with' unless @actual.count.positive?
|
15
|
+
|
16
|
+
# Check every entry
|
17
|
+
ok = true
|
18
|
+
@actual.each do |item|
|
19
|
+
ok = false unless RSpec::Rails::Api::Utils.validate_object_structure item, expected
|
20
|
+
end
|
21
|
+
|
22
|
+
ok
|
23
|
+
end
|
24
|
+
|
25
|
+
diffable
|
26
|
+
end
|
27
|
+
|
28
|
+
# FIXME: Split the matcher in something else; it's too messy.
|
29
|
+
RSpec::Matchers.define :have_one do |expected|
|
30
|
+
match do |actual|
|
31
|
+
@actual = actual
|
32
|
+
@actual = JSON.parse(actual.body) if actual.respond_to? :body
|
33
|
+
|
34
|
+
raise "Response is not a hash: #{@actual.class}" unless @actual.is_a? Hash
|
35
|
+
|
36
|
+
RSpec::Rails::Api::Utils.validate_object_structure @actual, expected
|
37
|
+
end
|
38
|
+
|
39
|
+
diffable
|
40
|
+
end
|
@@ -0,0 +1,178 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rspec/rails/api/utils'
|
4
|
+
require 'rspec/rails/api/open_api_renderer'
|
5
|
+
require 'rspec/rails/api/entity_config'
|
6
|
+
|
7
|
+
module RSpec
|
8
|
+
module Rails
|
9
|
+
module Api
|
10
|
+
# Handles contexts and examples metadatas.
|
11
|
+
class Metadata # rubocop:disable Metrics/ClassLength
|
12
|
+
attr_reader :entities, :resources, :current_resource
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
@resources = {}
|
16
|
+
@entities = {}
|
17
|
+
# Only used when building metadata during RSpec boot
|
18
|
+
@current_resource = nil
|
19
|
+
@current_method = nil
|
20
|
+
@current_url = nil
|
21
|
+
@current_code = nil
|
22
|
+
end
|
23
|
+
|
24
|
+
def add_resource(name, description)
|
25
|
+
@resources[name.to_sym] = { description: description, paths: {} }
|
26
|
+
@current_resource = name.to_sym
|
27
|
+
end
|
28
|
+
|
29
|
+
def add_entity(type, fields)
|
30
|
+
Utils.deep_set(@resources,
|
31
|
+
"#{@current_resource}.entities.#{type}",
|
32
|
+
EntityConfig.new(fields))
|
33
|
+
end
|
34
|
+
|
35
|
+
def add_path_params(fields) # rubocop:disable Metrics/MethodLength
|
36
|
+
check_current_context :resource, :url
|
37
|
+
|
38
|
+
chunks = @current_url.split('?')
|
39
|
+
|
40
|
+
fields.each do |name, field|
|
41
|
+
valid_attribute = Utils.check_attribute_type(field[:type], except: %i[array object])
|
42
|
+
raise "Field type not allowed: #{field[:type]}" unless valid_attribute
|
43
|
+
|
44
|
+
scope = path_param_scope(chunks, name)
|
45
|
+
Utils.deep_set(@resources, "#{@current_resource}.paths.#{@current_url}.path_params.#{name}",
|
46
|
+
description: field[:description] || nil,
|
47
|
+
type: field[:type] || nil,
|
48
|
+
required: field[:required] || true,
|
49
|
+
scope: scope)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# Fields should be something like:
|
54
|
+
# id: {type: :number, description: 'Something'},
|
55
|
+
# name: {type: string, description: 'Something'}
|
56
|
+
# Ex. with sub elements:
|
57
|
+
# id: {type: :number, description: 'Something'},
|
58
|
+
# something: {type: :object, description: 'Something', properties: {
|
59
|
+
# property: {type: :string, description: 'Something'},
|
60
|
+
# ...
|
61
|
+
# }}
|
62
|
+
def add_request_params(fields)
|
63
|
+
check_current_context :resource, :url, :method
|
64
|
+
|
65
|
+
params = organize_params fields
|
66
|
+
Utils.deep_set(@resources,
|
67
|
+
"#{@current_resource}.paths.#{@current_url}.actions.#{@current_method}.params",
|
68
|
+
params)
|
69
|
+
end
|
70
|
+
|
71
|
+
def add_action(method, url, description)
|
72
|
+
check_current_context :resource
|
73
|
+
|
74
|
+
Utils.deep_set(@resources, "#{@current_resource}.paths.#{url}.actions.#{method}",
|
75
|
+
description: description,
|
76
|
+
statuses: {},
|
77
|
+
params: [])
|
78
|
+
|
79
|
+
@current_url = url
|
80
|
+
@current_method = method
|
81
|
+
end
|
82
|
+
|
83
|
+
# rubocop:disable Metrics/LineLength
|
84
|
+
def add_status_code(status_code, description)
|
85
|
+
check_current_context :resource, :url, :method
|
86
|
+
|
87
|
+
Utils.deep_set(@resources,
|
88
|
+
"#{@current_resource}.paths.#{@current_url}.actions.#{@current_method}.statuses.#{status_code}",
|
89
|
+
description: description,
|
90
|
+
example: { response: nil })
|
91
|
+
@current_code = status_code
|
92
|
+
end
|
93
|
+
# rubocop:enable Metrics/LineLength
|
94
|
+
|
95
|
+
# rubocop:disable Metrics/ParameterLists
|
96
|
+
def add_request_example(url: nil, action: nil, status_code: nil, response: nil, path_params: nil, params: nil)
|
97
|
+
resource = nil
|
98
|
+
@resources.each do |key, res|
|
99
|
+
resource = key if Utils.deep_get(res, "paths.#{url}.actions.#{action}.statuses.#{status_code}")
|
100
|
+
end
|
101
|
+
|
102
|
+
raise "Resource not found for #{action.upcase} #{url}" unless resource
|
103
|
+
|
104
|
+
Utils.deep_set(@resources,
|
105
|
+
"#{resource}.paths.#{url}.actions.#{action}.statuses.#{status_code}.example",
|
106
|
+
path_params: path_params,
|
107
|
+
params: params,
|
108
|
+
response: response)
|
109
|
+
end
|
110
|
+
# rubocop:enable Metrics/ParameterLists
|
111
|
+
|
112
|
+
def to_h
|
113
|
+
{
|
114
|
+
resources: @resources,
|
115
|
+
entities: @entities,
|
116
|
+
}
|
117
|
+
end
|
118
|
+
|
119
|
+
private
|
120
|
+
|
121
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength, Style/GuardClause
|
122
|
+
def check_current_context(*scope)
|
123
|
+
scope ||= []
|
124
|
+
if scope.include?(:resource)
|
125
|
+
raise 'No resource declared' unless @current_resource
|
126
|
+
end
|
127
|
+
if scope.include?(:method)
|
128
|
+
raise 'No action declared' unless @current_method
|
129
|
+
end
|
130
|
+
if scope.include?(:url)
|
131
|
+
raise 'No url declared' unless @current_url
|
132
|
+
end
|
133
|
+
if scope.include?(:code)
|
134
|
+
raise 'No status code declared' unless @current_code
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength, Style/GuardClause
|
139
|
+
|
140
|
+
def path_param_scope(url_chunks, name)
|
141
|
+
if /:#{name}/ =~ url_chunks[0]
|
142
|
+
:path
|
143
|
+
elsif url_chunks[1] && /:#{name}/ =~ url_chunks[1]
|
144
|
+
:query
|
145
|
+
else
|
146
|
+
raise "#{name} not found in URL #{@current_url}"
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
def organize_params(fields) # rubocop:disable Metrics/AbcSize
|
151
|
+
out = { properties: {} }
|
152
|
+
required = []
|
153
|
+
fields.each do |name, field|
|
154
|
+
allowed_type = %i[array object].include?(field[:type]) || PARAM_TYPES.key?(field[:type])
|
155
|
+
raise "Field type not allowed: #{field[:type]}" unless allowed_type
|
156
|
+
|
157
|
+
required.push name.to_s if field[:required]
|
158
|
+
|
159
|
+
out[:properties][name] = fill_request_param field
|
160
|
+
end
|
161
|
+
out[:required] = required if required.count.positive?
|
162
|
+
out
|
163
|
+
end
|
164
|
+
|
165
|
+
def fill_request_param(field)
|
166
|
+
if field[:type] && field[:type] == :object
|
167
|
+
organize_params field[:properties] if field[:properties]
|
168
|
+
else
|
169
|
+
{
|
170
|
+
type: field[:type].to_s,
|
171
|
+
description: field[:description] || nil,
|
172
|
+
}
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
@@ -0,0 +1,215 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rspec/rails/api/utils'
|
4
|
+
require 'active_support'
|
5
|
+
|
6
|
+
module RSpec
|
7
|
+
module Rails
|
8
|
+
module Api
|
9
|
+
# Class to render metadatas.
|
10
|
+
# Example:
|
11
|
+
# ```rb
|
12
|
+
# renderer = RSpec::Rails::Api::OpenApiRenderer.new
|
13
|
+
# renderer.merge_context(example_context)
|
14
|
+
# renderer.write_files
|
15
|
+
# ```
|
16
|
+
class OpenApiRenderer # rubocop:disable Metrics/ClassLength
|
17
|
+
def initialize
|
18
|
+
@metadata = {
|
19
|
+
resources: {},
|
20
|
+
entities: {},
|
21
|
+
}
|
22
|
+
@api_infos = {}
|
23
|
+
@api_servers = {}
|
24
|
+
@api_paths = {}
|
25
|
+
@api_components = {}
|
26
|
+
@api_tags = []
|
27
|
+
end
|
28
|
+
|
29
|
+
def merge_context(context)
|
30
|
+
@metadata[:resources].deep_merge! context[:resources]
|
31
|
+
@metadata[:entities].deep_merge! context[:entities]
|
32
|
+
end
|
33
|
+
|
34
|
+
def write_files
|
35
|
+
content = prepare_metadata
|
36
|
+
File.write ::Rails.root.join('tmp', 'out.yaml'), content.to_yaml
|
37
|
+
File.write ::Rails.root.join('tmp', 'out.json'), content.to_json
|
38
|
+
end
|
39
|
+
|
40
|
+
def prepare_metadata
|
41
|
+
# Example: https://github.com/OAI/OpenAPI-Specification/blob/master/examples/v3.0/petstore-expanded.yaml
|
42
|
+
extract_metadatas
|
43
|
+
{
|
44
|
+
openapi: '3.0.0',
|
45
|
+
info: @api_infos,
|
46
|
+
servers: @api_servers,
|
47
|
+
paths: @api_paths,
|
48
|
+
components: @api_components,
|
49
|
+
tags: @api_tags,
|
50
|
+
}.deep_stringify_keys
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def extract_metadatas
|
56
|
+
extract_from_resources
|
57
|
+
extract_api_infos
|
58
|
+
extract_api_servers
|
59
|
+
end
|
60
|
+
|
61
|
+
def extract_from_resources
|
62
|
+
@metadata[:resources].each do |resource_key, resource|
|
63
|
+
@api_tags.push(
|
64
|
+
name: resource_key.to_s,
|
65
|
+
description: resource[:description]
|
66
|
+
)
|
67
|
+
process_resource resource: resource_key, resource_config: resource
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def process_resource(resource: nil, resource_config: nil) # rubocop:disable Metrics/MethodLength
|
72
|
+
resource_config[:paths].each do |path_key, path|
|
73
|
+
url = path_with_params path_key.to_s
|
74
|
+
actions = {}
|
75
|
+
parameters = path.key?(:path_params) ? process_path_params(path[:path_params]) : []
|
76
|
+
|
77
|
+
path[:actions].each_key do |action|
|
78
|
+
next unless %i[get post put patch delete].include? action
|
79
|
+
|
80
|
+
actions[action] = process_action resource: resource,
|
81
|
+
path: path_key,
|
82
|
+
path_config: path,
|
83
|
+
action_config: action,
|
84
|
+
parameters: parameters
|
85
|
+
end
|
86
|
+
|
87
|
+
@api_paths[url] = actions
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def process_path_params(params)
|
92
|
+
parameters = []
|
93
|
+
params.each do |name, param|
|
94
|
+
parameters.push process_path_param name, param
|
95
|
+
end
|
96
|
+
|
97
|
+
parameters
|
98
|
+
end
|
99
|
+
|
100
|
+
def process_path_param(name, param) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
101
|
+
parameter = {
|
102
|
+
name: name.to_s,
|
103
|
+
description: param[:description],
|
104
|
+
required: param[:required] || true,
|
105
|
+
in: param[:scope].to_s,
|
106
|
+
schema: {
|
107
|
+
type: PARAM_TYPES[param[:type]][:type],
|
108
|
+
},
|
109
|
+
}
|
110
|
+
|
111
|
+
parameter[:schema][:format] = PARAM_TYPES[param[:type]][:format] if PARAM_TYPES[param[:type]][:format]
|
112
|
+
|
113
|
+
parameter
|
114
|
+
end
|
115
|
+
|
116
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
117
|
+
def process_action(resource: nil, path: nil, path_config: nil, action_config: nil, parameters: nil)
|
118
|
+
responses = {}
|
119
|
+
request_body = nil
|
120
|
+
|
121
|
+
if %i[post put patch].include? action_config
|
122
|
+
if path_config[:actions][action_config][:params].keys.count.positive?
|
123
|
+
schema = path_config[:actions][action_config][:params]
|
124
|
+
schema_ref = escape_operation_id("#{action_config}_#{path}")
|
125
|
+
request_body = process_request_body schema: schema, ref: schema_ref
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
path_config[:actions][action_config][:statuses].each do |status_key, status|
|
130
|
+
content = status[:example][:response]
|
131
|
+
responses[status_key] = process_response status: status_key, status_config: status, content: content
|
132
|
+
end
|
133
|
+
|
134
|
+
description = path_config[:actions][action_config][:description]
|
135
|
+
action = {
|
136
|
+
description: description,
|
137
|
+
operationId: "#{resource} #{description}".downcase.gsub(/[^\w]/, '_'),
|
138
|
+
parameters: parameters,
|
139
|
+
responses: responses,
|
140
|
+
tags: [resource.to_s],
|
141
|
+
}
|
142
|
+
|
143
|
+
action[:requestBody] = request_body if request_body
|
144
|
+
|
145
|
+
action
|
146
|
+
end
|
147
|
+
|
148
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
149
|
+
|
150
|
+
def process_request_body(schema: nil, ref: nil)
|
151
|
+
Utils.deep_set @api_components, "schemas.#{ref}", schema
|
152
|
+
{
|
153
|
+
# description: '',
|
154
|
+
required: true,
|
155
|
+
content: {
|
156
|
+
'application/json' => {
|
157
|
+
schema: { '$ref' => "#/components/schemas/#{ref}" },
|
158
|
+
},
|
159
|
+
},
|
160
|
+
}
|
161
|
+
end
|
162
|
+
|
163
|
+
def process_response(status: nil, status_config: nil, content: nil)
|
164
|
+
response = {
|
165
|
+
description: status_config[:description],
|
166
|
+
}
|
167
|
+
|
168
|
+
return response unless status.to_s != '204' && content # No content
|
169
|
+
|
170
|
+
response[:content] = {
|
171
|
+
'application/json': {
|
172
|
+
examples: { default: { value: JSON.pretty_generate(JSON.parse(content)) } },
|
173
|
+
},
|
174
|
+
}
|
175
|
+
|
176
|
+
response
|
177
|
+
end
|
178
|
+
|
179
|
+
def path_with_params(string)
|
180
|
+
string.gsub(/(?::(\w*))/) do |e|
|
181
|
+
"{#{e.sub(':', '')}}"
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
def escape_operation_id(string)
|
186
|
+
string.downcase.gsub(/[^\w]+/, '_')
|
187
|
+
end
|
188
|
+
|
189
|
+
def extract_api_infos # rubocop:disable Metrics/MethodLength
|
190
|
+
@api_infos = {
|
191
|
+
title: 'Some sample app',
|
192
|
+
version: '1.0',
|
193
|
+
description: 'A nice API',
|
194
|
+
termsOfService: 'https://example.com/tos',
|
195
|
+
contact: {
|
196
|
+
name: 'API Team',
|
197
|
+
email: 'api-team@example.com',
|
198
|
+
url: 'http://example.com',
|
199
|
+
},
|
200
|
+
license: {
|
201
|
+
name: 'Apache 2',
|
202
|
+
url: 'https://url-to-license.com',
|
203
|
+
},
|
204
|
+
}
|
205
|
+
end
|
206
|
+
|
207
|
+
def extract_api_servers
|
208
|
+
@api_servers = [
|
209
|
+
{ url: 'http://api.example.com' },
|
210
|
+
]
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/hash_with_indifferent_access'
|
4
|
+
|
5
|
+
module RSpec
|
6
|
+
module Rails
|
7
|
+
module Api
|
8
|
+
# Helper methods
|
9
|
+
class Utils
|
10
|
+
def self.deep_get(hash, path)
|
11
|
+
path.split('.').inject(hash) do |sub_hash, key|
|
12
|
+
return nil unless sub_hash.is_a?(Hash) && sub_hash.key?(key.to_sym)
|
13
|
+
|
14
|
+
sub_hash[key.to_sym]
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.deep_set(hash, path, value)
|
19
|
+
path = path.split('.') unless path.is_a? Array
|
20
|
+
|
21
|
+
return value if path.count.zero?
|
22
|
+
|
23
|
+
current_key = path.shift.to_sym
|
24
|
+
hash[current_key] = {} unless hash[current_key].is_a?(Hash)
|
25
|
+
hash[current_key] = deep_set(hash[current_key], path, value)
|
26
|
+
|
27
|
+
hash
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.check_value_type(type, value) # rubocop:disable Metrics/CyclomaticComplexity
|
31
|
+
return true if type == :boolean && (value.is_a?(TrueClass) || value.is_a?(FalseClass))
|
32
|
+
return true if type == :array && value.is_a?(Array)
|
33
|
+
|
34
|
+
raise "Unknown type #{type}" unless PARAM_TYPES.key? type
|
35
|
+
|
36
|
+
value.is_a? PARAM_TYPES[type][:class]
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.validate_object_structure(actual, expected)
|
40
|
+
# Check keys
|
41
|
+
return false unless same_keys? actual, expected
|
42
|
+
|
43
|
+
expected.each_key do |key|
|
44
|
+
next unless expected[key][:required]
|
45
|
+
|
46
|
+
expected_type = expected[key][:type]
|
47
|
+
expected_attributes = expected[key][:attributes]
|
48
|
+
|
49
|
+
# Type
|
50
|
+
return false unless check_value_type expected_type, actual[key.to_s]
|
51
|
+
|
52
|
+
# Deep object ?
|
53
|
+
return false unless validate_deep_object expected_type, expected_attributes, actual[key.to_s]
|
54
|
+
end
|
55
|
+
|
56
|
+
true
|
57
|
+
end
|
58
|
+
|
59
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
|
60
|
+
def self.validate_deep_object(expected_type, expected_attributes, actual)
|
61
|
+
if %i[object array].include?(expected_type) && expected_attributes.is_a?(Hash)
|
62
|
+
case expected_type
|
63
|
+
when :object
|
64
|
+
return false unless validate_object_structure actual, expected_attributes
|
65
|
+
when :array
|
66
|
+
actual.each do |array_entry|
|
67
|
+
return false unless validate_object_structure array_entry, expected_attributes
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
true
|
73
|
+
end
|
74
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
|
75
|
+
|
76
|
+
def self.same_keys?(actual, expected)
|
77
|
+
optional = expected.reject { |_key, value| value[:required] }.keys
|
78
|
+
actual.symbolize_keys.keys.sort - optional == expected.keys.sort - optional
|
79
|
+
end
|
80
|
+
|
81
|
+
def self.check_attribute_type(type, except: [])
|
82
|
+
keys = PARAM_TYPES.keys.reject { |key| except.include? key }
|
83
|
+
keys.include?(type)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|