ropen_pi 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,86 @@
1
+ version: '3.7'
2
+
3
+ # shared variables to keep things DRY
4
+ x-shared-env: &shared
5
+ DB_HOST: ${DB_HOST}
6
+ DB_USER: ${DB_USER}
7
+ DB_NAME: ${DB_NAME}
8
+ DB_PASSWORD: ${DB_PASSWORD}
9
+ SVC_NAME: ${SVC_NAME}
10
+ SVC_SHORT: ${SVC_SHORT}
11
+
12
+ networks:
13
+ net__backend: ~
14
+
15
+ netext__traefik:
16
+ external: true
17
+
18
+ volumes:
19
+ # saves the gems as a mount that's used by docker
20
+ bundle: ~
21
+
22
+ services:
23
+ svc:
24
+ container_name: ${SVC_NAME}
25
+ depends_on:
26
+ - db
27
+ entrypoint: resources/docker/docker-entrypoint.sh
28
+ environment:
29
+ <<: *shared
30
+ image: talentplatforms/ruby:${RUBY_IMAGE_VERSION}
31
+ labels:
32
+ - org.label-schema.name=${SVC_NAME}
33
+ - org.label-schema.description=${SVC_DESCRIPTION}
34
+ - org.label-schema.version=1.0.0
35
+ networks:
36
+ - net__backend
37
+ stdin_open: true
38
+ # the tmp folder should not be kept, so it's an ephemeral mount
39
+ # this also speeds up requests
40
+ tmpfs:
41
+ - /app/log
42
+ - /app/tmp
43
+ tty: true
44
+ volumes:
45
+ - .:/app:cached
46
+ - bundle:/bundle
47
+
48
+ db:
49
+ container_name: ${DB_HOST}
50
+ environment:
51
+ POSTGRES_USER: ${DB_USER}
52
+ POSTGRES_PASSWORD: ${DB_PASSWORD}
53
+ POSTGRES_DB: ${DB_NAME}
54
+ image: postgres:10-alpine
55
+ networks:
56
+ - net__backend
57
+ ports:
58
+ # the db port to be used by external guis. this also needs to be changed
59
+ - ${DB_EXPOSED_PORT}:5432
60
+
61
+ # this is just a development helper that displays the
62
+ # created openApi documentation
63
+ # https://github.com/swagger-api/swagger-ui/tree/master/docs
64
+ # swagger:
65
+ # container_name: ${SVC_NAME}-swagger
66
+ # environment:
67
+ # URLS: "[{ url: \"${SWAGGER_ENDPOINT}\", name: \"${SVC_NAME}\" }]"
68
+ # image: swaggerapi/swagger-ui:v3.23.9
69
+ # labels:
70
+ # # traefik stuff
71
+ # - traefik.enable=true
72
+ # - traefik.docker.network=netext__traefik
73
+ # # http
74
+ # - traefik.http.routers.${SVC_NAME}-swagger.rule=Host(`swagger.${SVC_NAME}.localhost`)
75
+ # - traefik.http.routers.${SVC_NAME}-swagger.entrypoints=http
76
+ # - traefik.http.routers.${SVC_NAME}-swagger.service=${SVC_NAME}-swagger
77
+ # - traefik.http.routers.${SVC_NAME}-swagger.middlewares.redirect=redirect@file
78
+ # # https
79
+ # - traefik.http.routers.${SVC_NAME}-swagger-ssl.rule=Host(`swagger.${SVC_NAME}.localhost`)
80
+ # - traefik.http.routers.${SVC_NAME}-swagger-ssl.entrypoints=https
81
+ # - traefik.http.routers.${SVC_NAME}-swagger-ssl.tls=true
82
+ # - traefik.http.services.${SVC_NAME}-swagger.loadbalancer.server.port=8080
83
+ # networks:
84
+ # - netext__traefik
85
+ # volumes:
86
+ # - ./resources/open-api:/swagger
@@ -0,0 +1,15 @@
1
+ #!/bin/bash
2
+
3
+ set -ex
4
+
5
+ # Check or install the app dependencies via Bundler:
6
+ bundle check || bundle
7
+
8
+ # Specify a default command, in case it wasn't issued:
9
+ if [ -z "$1" ]; then
10
+ set -- bundle exec rails s -b 0.0.0.0 -p 3000 "$@"
11
+ fi
12
+
13
+ # Execute the given or default command:
14
+ exec "$@"
15
+
@@ -0,0 +1,8 @@
1
+ Description:
2
+ Adds open_api_helper to enable Swagger DSL in integration specs
3
+
4
+ Example:
5
+ rails generate rswag:specs:install
6
+
7
+ This will create:
8
+ spec/open_api_helper.rb
@@ -0,0 +1,12 @@
1
+ require 'rails/generators'
2
+ module Generators
3
+ module RopenPi
4
+ class InstallGenerator < Rails::Generators::Base
5
+ source_root File.expand_path('templates', __dir__)
6
+
7
+ def add_open_api_helper
8
+ template('ropen_pi_helper.rb', 'spec/ropen_pi_helper.rb')
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,25 @@
1
+ require 'rails_helper'
2
+
3
+ RSpec.configure do |config|
4
+ config.root_dir = Rails.root.join('open-api').to_s
5
+ config.open_api_docs = {
6
+ 'v1/openapi.json' => {
7
+ openapi: '3.0.0',
8
+ info: {
9
+ title: 'API V1',
10
+ version: 'v1'
11
+ },
12
+ paths: {},
13
+ servers: [
14
+ {
15
+ url: 'https://{defaultHost}',
16
+ variables: {
17
+ defaultHost: {
18
+ default: 'www.example.com'
19
+ }
20
+ }
21
+ }
22
+ ]
23
+ }
24
+ }
25
+ end
@@ -0,0 +1,5 @@
1
+ require 'ropen_pi/specs'
2
+ require 'ropen_pi/version'
3
+
4
+ module RopenPi
5
+ end
@@ -0,0 +1,144 @@
1
+ # helper for the open api documentation
2
+ module RopenPi
3
+ module Param
4
+ #
5
+ def self.date_param(name, desc: 'tba', opts: {})
6
+ param(name, :string, fmt: 'date-time', desc: desc, opts: opts)
7
+ end
8
+
9
+ def self.uuid_param(name, desc: 'tba', opts: {})
10
+ param(name, :string, fmt: 'uuid', desc: desc, opts: opts)
11
+ end
12
+
13
+ def self.string_param(name, desc: 'tba', opts: {})
14
+ param(name, :string, desc: desc, opts: opts)
15
+ end
16
+
17
+ def self.int_param(name, desc: 'tba', opts: {})
18
+ param(name, :integer, desc: desc, opts: opts)
19
+ end
20
+
21
+ def self.bool_param(name, desc: 'tba', opts: {})
22
+ param(name, :boolean, desc: desc, opts: opts)
23
+ end
24
+
25
+ def self.ref_param(name, ref, desc: 'tba', opts: {})
26
+ param(name, 'null', desc: desc, opts: opts).merge(schema: { '$ref': ref })
27
+ end
28
+
29
+ def self.param_in_path(name, schema_type, fmt: nil, desc: 'tba', opts: {})
30
+ param(name, schema_type, fmt: fmt, desc: desc, opts: opts)
31
+ .merge(
32
+ in: 'path',
33
+ required: true
34
+ )
35
+ .merge(opts)
36
+ end
37
+
38
+ def self.param(name, schema_type, fmt: nil, desc: 'tba', opts: {})
39
+ {
40
+ name: name.to_s,
41
+ description: desc,
42
+ in: 'query',
43
+ schema: schema(schema_type, fmt: fmt)
44
+ }.merge(opts)
45
+ end
46
+
47
+ def self.schema(type, fmt: nil)
48
+ {}.tap do |schema|
49
+ schema[:type] = type.to_s
50
+ schema[:format] = fmt.to_s if fmt.present?
51
+ end
52
+ end
53
+
54
+ def self.schema_enum(values, type: :string)
55
+ {
56
+ type: type.to_s,
57
+ enum: values
58
+ }
59
+ end
60
+
61
+ class << self
62
+ alias boolean_param bool_param
63
+ alias integer_param int_param
64
+ end
65
+ end
66
+
67
+ module Type
68
+ def self.date_time_type(opts = { example: '2020-02-02' })
69
+ string_type(opts).merge(format: 'date-time')
70
+ end
71
+
72
+ def self.email_type(opts = { example: 'han.solo@example.com' })
73
+ string_type(opts).merge(format: 'email')
74
+ end
75
+
76
+ def self.uuid_type(opts = { example: 'abcd12-1234ab-abcdef123' })
77
+ string_type(opts).merge(format: 'uuid')
78
+ end
79
+
80
+ def self.string_type(opts = { example: 'Example string' })
81
+ type('string', opts)
82
+ end
83
+
84
+ def self.integer_type(opts = { example: 1 })
85
+ type('integer', opts)
86
+ end
87
+
88
+ def self.bool_type(opts = { example: true })
89
+ type('boolean', opts)
90
+ end
91
+
92
+ def self.type(thing, opts = {})
93
+ { type: thing }.merge(opts)
94
+ end
95
+
96
+ def self.string_array_type(opts = {})
97
+ {
98
+ type: 'array',
99
+ items: { type: 'string', example: 'Example string' }
100
+ }.merge(opts)
101
+ end
102
+
103
+ def self.ref_type(ref)
104
+ { '$ref': ref }
105
+ end
106
+
107
+ class << self
108
+ alias boolean_type bool_type
109
+ alias int_type integer_type
110
+ end
111
+ end
112
+
113
+ module Response
114
+ # rubocop:disable Metrics/MethodLength
115
+ def self.collection(ref, desc: 'tba', type: RopenPi::APP_JSON)
116
+ {
117
+ description: desc,
118
+ content: {
119
+ type => {
120
+ schema: {
121
+ type: 'object',
122
+ properties: { data: { type: :array, items: { '$ref': ref } } }
123
+ }
124
+ }
125
+ }
126
+ }
127
+ end
128
+
129
+ def self.single(ref, desc: 'tba', type: RopenPi::APP_JSON)
130
+ {
131
+ description: desc,
132
+ content: {
133
+ type => {
134
+ schema: {
135
+ type: 'object',
136
+ properties: { data: { '$ref': ref } }
137
+ }
138
+ }
139
+ }
140
+ }
141
+ end
142
+ # rubocop:enable Metrics/MethodLength
143
+ end
144
+ end
@@ -0,0 +1,23 @@
1
+ require 'rspec/core'
2
+ require 'ropen_pi/specs/example_group_helpers'
3
+ require 'ropen_pi/specs/example_helpers'
4
+ require 'ropen_pi/specs/configuration'
5
+ require 'ropen_pi/specs/railtie' if defined?(Rails::Railtie)
6
+
7
+ module RopenPi
8
+ module Specs
9
+ # Extend RSpec with a swagger-based DSL
10
+ ::RSpec.configure do |config|
11
+ config.add_setting :root_dir
12
+ config.add_setting :open_api_docs
13
+ config.add_setting :open_api_output_format
14
+
15
+ config.extend ExampleGroupHelpers, type: :request
16
+ config.include ExampleHelpers, type: :request
17
+ end
18
+
19
+ def self.config
20
+ @config ||= Configuration.new(RSpec.configuration)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+ module RopenPi
3
+ module Specs
4
+ class Configuration
5
+ def initialize(rspec_config)
6
+ @rspec_config = rspec_config
7
+ end
8
+
9
+ def root_dir
10
+ @root_dir ||= begin
11
+ raise ConfigurationError, 'No root_dir provided. See open_api_helper.rb' if @rspec_config.root_dir.nil?
12
+
13
+ @rspec_config.root_dir
14
+ end
15
+ end
16
+
17
+ def open_api_output_format
18
+ @open_api_output_format ||= begin
19
+ @rspec_config.open_api_output_format || :json
20
+ end
21
+ end
22
+
23
+ def open_api_docs
24
+ @open_api_docs ||= begin
25
+ if @rspec_config.open_api_docs.nil? || @rspec_config.open_api_docs.empty?
26
+ raise ConfigurationError, 'No open_api_docs defined. See open_api_helper.rb'
27
+ end
28
+
29
+ @rspec_config.open_api_docs
30
+ end
31
+ end
32
+
33
+ def get_doc(name)
34
+ return open_api_docs.values.first if name.nil?
35
+
36
+ raise ConfigurationError, "Unknown doc '#{name}'" unless open_api_docs[name]
37
+
38
+ open_api_docs[name]
39
+ end
40
+ end
41
+
42
+ class ConfigurationError < StandardError; end
43
+ end
44
+ end
45
+
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+ # @TODO: replace hashie!
3
+ require 'hashie'
4
+
5
+ module RopenPi
6
+ module Specs
7
+ # rubocop:disable Metrics/ModuleLength
8
+ module ExampleGroupHelpers
9
+ def path(template, metadata = {}, &block)
10
+ metadata[:path_item] = { template: template }
11
+ describe(template, metadata, &block)
12
+ end
13
+
14
+ %i[get post patch put delete head].each do |verb|
15
+ define_method(verb) do |summary, &block|
16
+ api_metadata = { operation: { verb: verb, summary: summary } }
17
+ describe(verb, api_metadata, &block)
18
+ end
19
+ end
20
+
21
+ %i[operationId deprecated security].each do |attr_name|
22
+ define_method(attr_name) do |value|
23
+ metadata[:operation][attr_name] = value
24
+ end
25
+ end
26
+
27
+ # NOTE: 'description' requires special treatment because ExampleGroup already
28
+ # defines a method with that name. Provide an override that supports the existing
29
+ # functionality while also setting the appropriate metadata if applicable
30
+ def description(value = nil)
31
+ return super() if value.nil?
32
+
33
+ metadata[:operation][:description] = value
34
+ end
35
+
36
+ # These are array properties - note the splat operator
37
+ %i[tags consumes produces schemes].each do |attr_name|
38
+ define_method(attr_name) do |*value|
39
+ metadata[:operation][attr_name] = value
40
+ end
41
+ end
42
+
43
+ def request_body(attributes)
44
+ # can make this generic, and accept any incoming hash (like parameter method)
45
+ attributes.compact!
46
+
47
+ if metadata[:operation][:requestBody].blank?
48
+ metadata[:operation][:requestBody] = attributes
49
+ elsif metadata[:operation][:requestBody] && metadata[:operation][:requestBody][:content]
50
+ # merge in
51
+ content_hash = metadata[:operation][:requestBody][:content]
52
+ incoming_content_hash = attributes[:content]
53
+ content_hash.merge!(incoming_content_hash) if incoming_content_hash
54
+ end
55
+ end
56
+
57
+ def request_body_json(schema:, required: true, description: nil, examples: nil)
58
+ passed_examples = Array(examples)
59
+ content_hash = { 'application/json' => { schema: schema, examples: examples }.compact! || {} }
60
+ request_body(description: description, required: required, content: content_hash)
61
+
62
+ handle_examples(schema: schema, examples: passed_examples, required: required) if passed_examples.any?
63
+ end
64
+
65
+ # rubocop:disable Metrics/MethodLength
66
+ def handle_examples(schema:, examples:, required:)
67
+ # the request_factory is going to have to resolve the different ways that the example can be given
68
+ # it can contain a 'value' key which is a direct hash (easiest)
69
+ # it can contain a 'external_value' key which makes an external call to load the json
70
+ # it can contain a '$ref' key. Which points to #/components/examples/blog
71
+ # rubocop:disable Metrics/BlockLength
72
+ examples.each do |passed_example|
73
+ param_attributes = if passed_example.is_a?(Symbol)
74
+ example_key_name = passed_example
75
+ # TODO: write more tests around this adding to the parameter
76
+ # if symbol try and use save_request_example
77
+ {
78
+ name: example_key_name,
79
+ in: :body,
80
+ required: required,
81
+ param_value: example_key_name,
82
+ schema: schema
83
+ }
84
+ elsif passed_example.is_a?(Hash) && passed_example[:externalValue]
85
+ {
86
+ name: passed_example,
87
+ in: :body,
88
+ required: required,
89
+ param_value: passed_example[:externalValue],
90
+ schema: schema
91
+ }
92
+ elsif passed_example.is_a?(Hash) && passed_example['$ref']
93
+ {
94
+ name: passed_example,
95
+ in: :body,
96
+ required: required,
97
+ param_value: passed_example['$ref'],
98
+ schema: schema
99
+ }
100
+ end
101
+ parameter(param_attributes)
102
+ end
103
+ # rubocop:enable Metrics/BlockLength
104
+ end
105
+ # rubocop:enable Metrics/MethodLength
106
+
107
+ def request_body_text_plain(required: false, description: nil, examples: nil)
108
+ content_hash = { 'test/plain' => { schema: {type: :string}, examples: examples }.compact! || {} }
109
+ request_body(description: description, required: required, content: content_hash)
110
+ end
111
+
112
+ # TODO: add examples to this like we can for json, might be large lift
113
+ # as many assumptions are made on content-type
114
+ def request_body_xml(schema:, required: false, description: nil, examples: nil)
115
+ passed_examples = Array(examples)
116
+ content_hash = { 'application/xml' => { schema: schema, examples: examples }.compact! || {} }
117
+ request_body(description: description, required: required, content: content_hash)
118
+ end
119
+
120
+ def request_body_multipart(schema:, description: nil)
121
+ content_hash = { 'multipart/form-data' => { schema: schema } }
122
+ request_body(description: description, content: content_hash)
123
+
124
+ schema.extend(Hashie::Extensions::DeepLocate)
125
+ file_properties = schema.deep_locate ->(_k, v, _obj) { v == :binary }
126
+ hash_locator = []
127
+
128
+ file_properties.each do |match|
129
+ hash_match = schema.deep_locate ->(_k, v, _obj) { v == match }
130
+ hash_locator.concat(hash_match) unless hash_match.empty?
131
+ end
132
+
133
+ property_hashes = hash_locator.flat_map do |locator|
134
+ locator.select { |_k, v| file_properties.include?(v) }
135
+ end
136
+
137
+ existing_keys = []
138
+ property_hashes.each do |property_hash|
139
+ property_hash.keys.each do |k|
140
+ next if existing_keys.include?(k)
141
+
142
+ file_name = k
143
+ existing_keys << k
144
+ parameter name: file_name, in: :formData, type: :file, required: true
145
+ end
146
+ end
147
+ end
148
+
149
+ def parameter(attributes)
150
+ attributes[:required] = true if attributes[:in] && attributes[:in].to_sym == :path
151
+ attributes[:schema] = { type: attributes[:type] } if attributes[:type] && attributes[:schema].nil?
152
+
153
+ if metadata.key?(:operation)
154
+ metadata[:operation][:parameters] ||= []
155
+ metadata[:operation][:parameters] << attributes
156
+ else
157
+ metadata[:path_item][:parameters] ||= []
158
+ metadata[:path_item][:parameters] << attributes
159
+ end
160
+ end
161
+
162
+ def response(code, description, metadata = {}, &block)
163
+ metadata[:response] = { code: code, description: description }
164
+ context(description, metadata, &block)
165
+ end
166
+
167
+ def schema(value, content_type: 'application/json')
168
+ content_hash = { content_type => { schema: value } }
169
+ metadata[:response][:content] = content_hash
170
+ end
171
+
172
+ def header(name, attributes)
173
+ metadata[:response][:headers] ||= {}
174
+
175
+ if attributes[:type] && attributes[:schema].nil?
176
+ attributes[:schema] = { type: attributes[:type] }
177
+ attributes.delete(:type)
178
+ end
179
+
180
+ metadata[:response][:headers][name] = attributes
181
+ end
182
+
183
+ # NOTE: Similar to 'description', 'examples' need to handle the case when
184
+ # being invoked with no params to avoid overriding 'examples' method of
185
+ # rspec-core ExampleGroup
186
+ def examples(example = nil)
187
+ return super() if example.nil?
188
+
189
+ metadata[:response][:examples] = example
190
+ end
191
+
192
+ # checks the examples in the parameters should be able to add $ref and externalValue examples.
193
+ # This syntax would look something like this in the integration _spec.rb file
194
+ #
195
+ # request_body_json schema: { '$ref' => '#/components/schemas/blog' },
196
+ # examples: [:blog, {name: :external_blog,
197
+ # externalValue: 'http://api.sample.org/myjson_example'},
198
+ # {name: :another_example,
199
+ # '$ref' => '#/components/examples/flexible_blog_example'}]
200
+ # The first value :blog, points to a let param of the same name, and is used to make the request in the
201
+ # integration test (it is used to build the request payload)
202
+ #
203
+ # The second item in the array shows how to add an externalValue for the examples in the requestBody section
204
+ # The third item shows how to add a $ref item that points to the components/examples section of the swagger spec.
205
+ #
206
+ # NOTE: that the externalValue will produce valid example syntax in the swagger output, but swagger-ui
207
+ # will not show it yet
208
+ def merge_other_examples!(example_metadata)
209
+ # example.metadata[:operation][:requestBody][:content]['application/json'][:examples]
210
+ content_node = example_metadata[:operation][:requestBody][:content]['application/json']
211
+ return unless content_node
212
+
213
+ external_example = example_metadata[:operation]&.dig(:parameters)&.detect do |p|
214
+ p[:in] == :body && p[:name].is_a?(Hash) && p[:name][:externalValue]
215
+ end || {}
216
+
217
+ ref_example = example_metadata[:operation]&.dig(:parameters)&.detect do |p|
218
+ p[:in] == :body && p[:name].is_a?(Hash) && p[:name]['$ref']
219
+ end || {}
220
+
221
+ examples_node = content_node[:examples] ||= {}
222
+
223
+ nodes_to_add = []
224
+ nodes_to_add << external_example unless external_example.empty?
225
+ nodes_to_add << ref_example unless ref_example.empty?
226
+
227
+ nodes_to_add.each do |node|
228
+ json_request_examples = examples_node ||= {}
229
+ other_name = node[:name][:name]
230
+ other_key = node[:name][:externalValue] ? :externalValue : '$ref'
231
+
232
+ json_request_examples.merge!(other_name => { other_key => node[:param_value] }) if other_name
233
+ end
234
+ end
235
+
236
+ def run_test!(&block)
237
+ app_json = 'application/json'
238
+
239
+ before do |example|
240
+ submit_request(example.metadata)
241
+ end
242
+
243
+ it "returns a #{metadata[:response][:code]} response" do |example|
244
+ assert_response_matches_metadata(example.metadata, &block)
245
+ example.instance_exec(response, &block) if block_given?
246
+ end
247
+
248
+ after do |example|
249
+ body_parameter = example.metadata[:operation]&.dig(:parameters)&.detect do |p|
250
+ p[:in] == :body && p[:required]
251
+ end
252
+
253
+ if body_parameter && respond_to?(body_parameter[:name]) && \
254
+ example.metadata[:operation][:requestBody][:content][app_json]
255
+
256
+ # save response examples by default
257
+ if example.metadata[:response][:examples].nil? || example.metadata[:response][:examples].empty?
258
+ unless response.body.to_s.empty?
259
+ example.metadata[:response][:examples] = {
260
+ app_json => JSON.parse(response.body, symbolize_names: true)
261
+ }
262
+ end
263
+ end
264
+
265
+ # save request examples using the let(:param_name) { REQUEST_BODY_HASH } syntax in the test
266
+ if response.code.to_s =~ /^2\d{2}$/
267
+
268
+ example.metadata[:operation][:requestBody][:content][app_json] = { examples: {} } \
269
+ unless example.metadata[:operation][:requestBody][:content][app_json][:examples]
270
+
271
+ json_request_examples = example.metadata[:operation][:requestBody][:content][app_json][:examples]
272
+ json_request_examples[body_parameter[:name]] = { value: send(body_parameter[:name]) }
273
+
274
+ example.metadata[:operation][:requestBody][:content][app_json][:examples] = json_request_examples
275
+ end
276
+ end
277
+
278
+ self.class.merge_other_examples!(example.metadata) if example.metadata[:operation][:requestBody]
279
+ end
280
+ end
281
+ end
282
+ # rubocop:enable Metrics/ModuleLength
283
+ end
284
+ end