rspec-rails-api 0.1.0
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 +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
|