apicraft-rails 0.5.0.beta1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|