apicraft-rails 0.5.0.beta1
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/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/README.md +257 -0
- data/apicraft-rails.gemspec +41 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/lib/apicraft/concerns/cacheable.rb +30 -0
- data/lib/apicraft/concerns.rb +8 -0
- data/lib/apicraft/config.rb +76 -0
- data/lib/apicraft/constants.rb +28 -0
- data/lib/apicraft/errors.rb +11 -0
- data/lib/apicraft/loader.rb +50 -0
- data/lib/apicraft/middlewares/introspector.rb +42 -0
- data/lib/apicraft/middlewares/mocker.rb +69 -0
- data/lib/apicraft/middlewares.rb +9 -0
- data/lib/apicraft/mocker/all_of.rb +27 -0
- data/lib/apicraft/mocker/any_of.rb +31 -0
- data/lib/apicraft/mocker/array.rb +33 -0
- data/lib/apicraft/mocker/base.rb +16 -0
- data/lib/apicraft/mocker/boolean.rb +12 -0
- data/lib/apicraft/mocker/integer.rb +12 -0
- data/lib/apicraft/mocker/number.rb +43 -0
- data/lib/apicraft/mocker/object.rb +35 -0
- data/lib/apicraft/mocker/one_of.rb +14 -0
- data/lib/apicraft/mocker/string.rb +44 -0
- data/lib/apicraft/mocker.rb +41 -0
- data/lib/apicraft/openapi/contract.rb +50 -0
- data/lib/apicraft/openapi/operation.rb +36 -0
- data/lib/apicraft/openapi/response.rb +47 -0
- data/lib/apicraft/openapi.rb +10 -0
- data/lib/apicraft/railtie.rb +11 -0
- data/lib/apicraft/version.rb +6 -0
- data/lib/apicraft/web/actions.rb +60 -0
- data/lib/apicraft/web/app.rb +65 -0
- data/lib/apicraft/web/router.rb +62 -0
- data/lib/apicraft/web.rb +10 -0
- data/lib/apicraft-rails.rb +37 -0
- data/web/views/index.html +15 -0
- data/web/views/redoc.erb +85 -0
- data/web/views/swaggerdoc.erb +38 -0
- metadata +187 -0
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Apicraft
|
4
|
+
module Middlewares
|
5
|
+
# Apicraft Middleware to handle routing
|
6
|
+
# and make mock calls available.
|
7
|
+
class Mocker
|
8
|
+
def initialize(app)
|
9
|
+
@app = app
|
10
|
+
end
|
11
|
+
|
12
|
+
def call(env)
|
13
|
+
return @app.call(env) unless config.mocks
|
14
|
+
|
15
|
+
request = ActionDispatch::Request.new(env)
|
16
|
+
return @app.call(env) unless mock?(request)
|
17
|
+
|
18
|
+
contract = Apicraft::Openapi::Contract.find_by_operation(
|
19
|
+
request.method, request.path_info
|
20
|
+
)
|
21
|
+
raise Errors::InvalidContract if contract.blank?
|
22
|
+
|
23
|
+
operation = contract.operation(
|
24
|
+
request.method, request.path_info
|
25
|
+
)
|
26
|
+
raise Errors::InvalidOperation if operation.blank?
|
27
|
+
|
28
|
+
code = request.headers[config.headers[:response_code]] || "200"
|
29
|
+
response = operation.response_for(code.to_s)
|
30
|
+
raise Errors::InvalidResponse if response.blank?
|
31
|
+
|
32
|
+
# Determine the format passed in the request.
|
33
|
+
# If passed we use it and the response format.
|
34
|
+
# If not we use the first format from the specs.
|
35
|
+
request.format.to_s
|
36
|
+
# indicates that not format was specified.
|
37
|
+
format = nil
|
38
|
+
|
39
|
+
content, content_type = response.mock(format)
|
40
|
+
|
41
|
+
[
|
42
|
+
code.to_i,
|
43
|
+
{
|
44
|
+
'Content-Type': content_type
|
45
|
+
},
|
46
|
+
[
|
47
|
+
content&.send(convertor(content_type))
|
48
|
+
].compact
|
49
|
+
]
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def config
|
55
|
+
@config ||= Apicraft.config
|
56
|
+
end
|
57
|
+
|
58
|
+
def convertor(format)
|
59
|
+
return if format.blank?
|
60
|
+
|
61
|
+
Apicraft::Constants::MIME_TYPE_CONVERTORS[format]
|
62
|
+
end
|
63
|
+
|
64
|
+
def mock?(request)
|
65
|
+
request.headers[config.headers[:mock]].present?
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Apicraft
|
4
|
+
module Mocker
|
5
|
+
# Generate mock based on the OneOf schema
|
6
|
+
class AllOf < Base
|
7
|
+
def mock
|
8
|
+
res = schema.all_of.map do |s|
|
9
|
+
Mocker.mock(s)
|
10
|
+
end
|
11
|
+
type = res[0].class
|
12
|
+
|
13
|
+
# TODO: This is not an accurate implementation
|
14
|
+
# especially when it comes to numbers and strings
|
15
|
+
# Ideally, we would want to combine rules from
|
16
|
+
# all the schemas and then generate a value.
|
17
|
+
if type.is_a?(Hash)
|
18
|
+
res.reduce(&:merge)
|
19
|
+
elsif type.is_a?(Array)
|
20
|
+
res.reduce(&:concat)
|
21
|
+
else
|
22
|
+
res.sample
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Apicraft
|
4
|
+
module Mocker
|
5
|
+
# Generate mock based on the AnyOf schema
|
6
|
+
class AnyOf < Base
|
7
|
+
def mock
|
8
|
+
num = schema.any_of.size.times.to_a.sample
|
9
|
+
|
10
|
+
# Pick a random number of schemas
|
11
|
+
# to build the response off of
|
12
|
+
res = schema.any_of.sample(num + 1).map do |s|
|
13
|
+
Mocker.mock(s)
|
14
|
+
end
|
15
|
+
type = res[0].class
|
16
|
+
|
17
|
+
# TODO: This is not an accurate implementation
|
18
|
+
# especially when it comes to numbers and strings
|
19
|
+
# Ideally, we would want to combine rules from
|
20
|
+
# all the schemas and then generate a value.
|
21
|
+
if type.is_a?(Hash)
|
22
|
+
res.reduce(&:merge)
|
23
|
+
elsif type.is_a?(Array)
|
24
|
+
res.reduce(&:concat)
|
25
|
+
else
|
26
|
+
res.sample
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Apicraft
|
4
|
+
module Mocker
|
5
|
+
# Generate an array based on the schema
|
6
|
+
# using Mocker types
|
7
|
+
class Array < Base
|
8
|
+
def mock
|
9
|
+
::Array.new(
|
10
|
+
(min_items..max_items)
|
11
|
+
.to_a
|
12
|
+
.sample
|
13
|
+
).map do |_i|
|
14
|
+
Mocker.mock(schema.items)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def max_items
|
21
|
+
schema.maxItems || 25
|
22
|
+
end
|
23
|
+
|
24
|
+
def min_items
|
25
|
+
schema.minItems || 1
|
26
|
+
end
|
27
|
+
|
28
|
+
def unique_items
|
29
|
+
schema.uniqueItems.present?
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Apicraft
|
4
|
+
module Mocker
|
5
|
+
# Base class for generating fake
|
6
|
+
# values for different data types.
|
7
|
+
# Check subclasses
|
8
|
+
class Base
|
9
|
+
attr_accessor :schema
|
10
|
+
|
11
|
+
def initialize(schema)
|
12
|
+
@schema = schema
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Apicraft
|
4
|
+
module Mocker
|
5
|
+
# Generate fake values for numbers
|
6
|
+
class Number < Base
|
7
|
+
def mock
|
8
|
+
return enum.sample if enum.present?
|
9
|
+
|
10
|
+
Faker::Number.between(
|
11
|
+
from: schema.minimum || 1,
|
12
|
+
to: schema.maximum || 100
|
13
|
+
) * multiple_of
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def minimum
|
19
|
+
schema.minimum || 1
|
20
|
+
end
|
21
|
+
|
22
|
+
def maximum
|
23
|
+
schema.maximum || 100
|
24
|
+
end
|
25
|
+
|
26
|
+
def exclusive_minimum
|
27
|
+
schema.exclusiveMinimum
|
28
|
+
end
|
29
|
+
|
30
|
+
def exclusive_maximum
|
31
|
+
schema.exclusiveMaximum
|
32
|
+
end
|
33
|
+
|
34
|
+
def multiple_of
|
35
|
+
schema.multipleOf || 1
|
36
|
+
end
|
37
|
+
|
38
|
+
def enum
|
39
|
+
schema.enum || []
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Apicraft
|
4
|
+
module Mocker
|
5
|
+
# Generate an object based on the schema
|
6
|
+
# using Mocker types
|
7
|
+
class Object < Base
|
8
|
+
def mock
|
9
|
+
res = {}
|
10
|
+
properties.each do |k, v|
|
11
|
+
res[k] = Mocker.mock(v)
|
12
|
+
end
|
13
|
+
res
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def required
|
19
|
+
schema.required || []
|
20
|
+
end
|
21
|
+
|
22
|
+
def min_properties
|
23
|
+
schema.minProperties || 0
|
24
|
+
end
|
25
|
+
|
26
|
+
def max_properties
|
27
|
+
schema.maxProperties || 0
|
28
|
+
end
|
29
|
+
|
30
|
+
def properties
|
31
|
+
schema.properties || {}
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Apicraft
|
4
|
+
module Mocker
|
5
|
+
# Generate fake values for string data types
|
6
|
+
class String < Base
|
7
|
+
def mock
|
8
|
+
return enum.sample if enum.present?
|
9
|
+
|
10
|
+
return Faker::Internet.email if format == "email"
|
11
|
+
|
12
|
+
return Faker::Internet.url if format == "uri"
|
13
|
+
|
14
|
+
return Faker::Internet.uuid if format == "uuid"
|
15
|
+
|
16
|
+
return Faker::Time.backward.iso8601 if format == "date-time"
|
17
|
+
|
18
|
+
Faker::Lorem.word
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def format
|
24
|
+
schema.format
|
25
|
+
end
|
26
|
+
|
27
|
+
def min_length
|
28
|
+
schema.minLength
|
29
|
+
end
|
30
|
+
|
31
|
+
def max_length
|
32
|
+
schema.maxLength
|
33
|
+
end
|
34
|
+
|
35
|
+
def pattern
|
36
|
+
schema.pattern
|
37
|
+
end
|
38
|
+
|
39
|
+
def enum
|
40
|
+
schema.enum || []
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "mocker/base"
|
4
|
+
require_relative "mocker/boolean"
|
5
|
+
require_relative "mocker/string"
|
6
|
+
require_relative "mocker/number"
|
7
|
+
require_relative "mocker/integer"
|
8
|
+
require_relative "mocker/object"
|
9
|
+
require_relative "mocker/array"
|
10
|
+
require_relative "mocker/one_of"
|
11
|
+
require_relative "mocker/all_of"
|
12
|
+
require_relative "mocker/any_of"
|
13
|
+
|
14
|
+
module Apicraft
|
15
|
+
# Namespace module for Mocker types
|
16
|
+
module Mocker
|
17
|
+
def self.handler_for(schema)
|
18
|
+
"Apicraft::Mocker::#{
|
19
|
+
extract_type(schema).camelize
|
20
|
+
}".constantize
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.mock(schema)
|
24
|
+
return if schema.blank?
|
25
|
+
|
26
|
+
handler_for(schema).new(
|
27
|
+
schema
|
28
|
+
).mock
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.extract_type(schema)
|
32
|
+
return schema.type if schema.type.present?
|
33
|
+
|
34
|
+
return "one_of" if schema.one_of.present?
|
35
|
+
|
36
|
+
return "any_of" if schema.any_of.present?
|
37
|
+
|
38
|
+
"all_of" if schema.all_of.present?
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Apicraft
|
4
|
+
module Openapi
|
5
|
+
# A single openapi yaml file is represented
|
6
|
+
# by this Contract class.
|
7
|
+
class Contract
|
8
|
+
include Concerns::Cacheable
|
9
|
+
@store = []
|
10
|
+
|
11
|
+
attr_accessor :document
|
12
|
+
|
13
|
+
def initialize(document)
|
14
|
+
@document = document
|
15
|
+
end
|
16
|
+
|
17
|
+
def operation(method, path)
|
18
|
+
with_cache("operation-#{method.downcase}-#{path}") do
|
19
|
+
op = document.request_operation(method.downcase, path)
|
20
|
+
Operation.new(op) if op.present?
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
class << self
|
25
|
+
def create!(document)
|
26
|
+
c = new(document)
|
27
|
+
@store << c
|
28
|
+
c
|
29
|
+
end
|
30
|
+
|
31
|
+
def all
|
32
|
+
@store
|
33
|
+
end
|
34
|
+
|
35
|
+
def find_by_operation(method, path)
|
36
|
+
found = nil
|
37
|
+
|
38
|
+
all.each do |c|
|
39
|
+
if c.operation(method, path).present?
|
40
|
+
found = c
|
41
|
+
break
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
found
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Apicraft
|
4
|
+
module Openapi
|
5
|
+
# Represents an OpenAPI operation.
|
6
|
+
# like GET /pets
|
7
|
+
class Operation
|
8
|
+
attr_accessor :operation
|
9
|
+
|
10
|
+
def initialize(operation)
|
11
|
+
@operation = operation.operation_object
|
12
|
+
end
|
13
|
+
|
14
|
+
def responses
|
15
|
+
@operation.responses
|
16
|
+
end
|
17
|
+
|
18
|
+
def summary
|
19
|
+
@operation.summary
|
20
|
+
end
|
21
|
+
|
22
|
+
def response_for(code)
|
23
|
+
response = responses.response[code.to_s]
|
24
|
+
return unless response.present?
|
25
|
+
|
26
|
+
Response.new(
|
27
|
+
response
|
28
|
+
)
|
29
|
+
end
|
30
|
+
|
31
|
+
def raw_schema
|
32
|
+
operation.raw_schema
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Apicraft
|
4
|
+
module Openapi
|
5
|
+
# Represents an OpenAPI response.
|
6
|
+
class Response
|
7
|
+
attr_accessor :response
|
8
|
+
|
9
|
+
def initialize(response)
|
10
|
+
@response = response
|
11
|
+
end
|
12
|
+
|
13
|
+
def description
|
14
|
+
response.description
|
15
|
+
end
|
16
|
+
|
17
|
+
def default_content_type
|
18
|
+
return if content.blank?
|
19
|
+
|
20
|
+
content.keys[0]
|
21
|
+
end
|
22
|
+
|
23
|
+
def content
|
24
|
+
response.content
|
25
|
+
end
|
26
|
+
|
27
|
+
def content_for(content_type = nil)
|
28
|
+
return if content.blank?
|
29
|
+
|
30
|
+
content[content_type || default_content_type]
|
31
|
+
end
|
32
|
+
|
33
|
+
def schema_for(content_type = nil)
|
34
|
+
content_for(content_type || default_content_type)&.schema
|
35
|
+
end
|
36
|
+
|
37
|
+
def mock(content_type = nil)
|
38
|
+
[
|
39
|
+
Mocker.mock(
|
40
|
+
schema_for(content_type || default_content_type)
|
41
|
+
),
|
42
|
+
content_type || default_content_type
|
43
|
+
]
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Apicraft
|
4
|
+
module Web
|
5
|
+
# Web actions to be handled from
|
6
|
+
# the rack app.
|
7
|
+
module Actions
|
8
|
+
def self.index(view_path)
|
9
|
+
[
|
10
|
+
File.read(view_path),
|
11
|
+
"text/html"
|
12
|
+
]
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.swaggerdoc(view_path)
|
16
|
+
@vars = {
|
17
|
+
urls: Router.contract_urls
|
18
|
+
}
|
19
|
+
|
20
|
+
[
|
21
|
+
ERB.new(
|
22
|
+
File.read(view_path)
|
23
|
+
).result(binding),
|
24
|
+
"text/html"
|
25
|
+
]
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.redoc(view_path)
|
29
|
+
@vars = {
|
30
|
+
urls: Router.contract_urls
|
31
|
+
}
|
32
|
+
|
33
|
+
[
|
34
|
+
ERB.new(
|
35
|
+
File.read(view_path)
|
36
|
+
).result(binding),
|
37
|
+
"text/html"
|
38
|
+
]
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.contract(view_path)
|
42
|
+
[
|
43
|
+
File.read(view_path),
|
44
|
+
MIME::Types.type_for(view_path)[0].to_s
|
45
|
+
]
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.introspect(method, view_path)
|
49
|
+
[
|
50
|
+
Apicraft::Openapi::Contract.find_by_operation(
|
51
|
+
method, view_path
|
52
|
+
)&.operation(
|
53
|
+
method, view_path
|
54
|
+
)&.raw_schema&.to_json,
|
55
|
+
"application/json"
|
56
|
+
]
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Apicraft
|
4
|
+
module Web
|
5
|
+
# Apicraft Rack App that is mounted
|
6
|
+
# for all the views to be served
|
7
|
+
class App
|
8
|
+
def self.call(env)
|
9
|
+
return unauthorized_response unless authorized?(env)
|
10
|
+
|
11
|
+
uri = env["REQUEST_URI"]
|
12
|
+
method = env["REQUEST_METHOD"]
|
13
|
+
Router.namespace = env["SCRIPT_NAME"]
|
14
|
+
path = uri.split(
|
15
|
+
Router.namespace
|
16
|
+
)[-1]
|
17
|
+
|
18
|
+
content, content_type = Router.load_response!(
|
19
|
+
method, path || "/"
|
20
|
+
)
|
21
|
+
|
22
|
+
raise Errors::RouteNotFound if content.nil?
|
23
|
+
|
24
|
+
[
|
25
|
+
200,
|
26
|
+
{ 'Content-Type': content_type },
|
27
|
+
[content]
|
28
|
+
]
|
29
|
+
rescue Errors::RouteNotFound
|
30
|
+
[
|
31
|
+
404,
|
32
|
+
{ 'Content-Type': "text/plain" },
|
33
|
+
["Error: not found"]
|
34
|
+
]
|
35
|
+
rescue StandardError => e
|
36
|
+
[
|
37
|
+
500,
|
38
|
+
{ 'Content-Type': "text/plain" },
|
39
|
+
["Error: #{e.message}"]
|
40
|
+
]
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.authorized?(env)
|
44
|
+
auth = Rack::Auth::Basic::Request.new(env)
|
45
|
+
username, password = auth.provided? && auth.basic? && auth.credentials
|
46
|
+
@use&.call(username, password).present?
|
47
|
+
end
|
48
|
+
|
49
|
+
def self.use(&block)
|
50
|
+
@use = block
|
51
|
+
end
|
52
|
+
|
53
|
+
def self.unauthorized_response
|
54
|
+
[
|
55
|
+
401,
|
56
|
+
{
|
57
|
+
"Content-Type": "text/plain",
|
58
|
+
"WWW-Authenticate": "Basic realm=\"Restricted Area\""
|
59
|
+
},
|
60
|
+
["Unauthorized"]
|
61
|
+
]
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|