ropen_pi 0.2.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.
@@ -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