apicraft-rails 0.5.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/CODE_OF_CONDUCT.md +84 -0
  4. data/README.md +257 -0
  5. data/apicraft-rails.gemspec +41 -0
  6. data/bin/console +15 -0
  7. data/bin/setup +8 -0
  8. data/lib/apicraft/concerns/cacheable.rb +30 -0
  9. data/lib/apicraft/concerns.rb +8 -0
  10. data/lib/apicraft/config.rb +76 -0
  11. data/lib/apicraft/constants.rb +28 -0
  12. data/lib/apicraft/errors.rb +11 -0
  13. data/lib/apicraft/loader.rb +50 -0
  14. data/lib/apicraft/middlewares/introspector.rb +42 -0
  15. data/lib/apicraft/middlewares/mocker.rb +69 -0
  16. data/lib/apicraft/middlewares.rb +9 -0
  17. data/lib/apicraft/mocker/all_of.rb +27 -0
  18. data/lib/apicraft/mocker/any_of.rb +31 -0
  19. data/lib/apicraft/mocker/array.rb +33 -0
  20. data/lib/apicraft/mocker/base.rb +16 -0
  21. data/lib/apicraft/mocker/boolean.rb +12 -0
  22. data/lib/apicraft/mocker/integer.rb +12 -0
  23. data/lib/apicraft/mocker/number.rb +43 -0
  24. data/lib/apicraft/mocker/object.rb +35 -0
  25. data/lib/apicraft/mocker/one_of.rb +14 -0
  26. data/lib/apicraft/mocker/string.rb +44 -0
  27. data/lib/apicraft/mocker.rb +41 -0
  28. data/lib/apicraft/openapi/contract.rb +50 -0
  29. data/lib/apicraft/openapi/operation.rb +36 -0
  30. data/lib/apicraft/openapi/response.rb +47 -0
  31. data/lib/apicraft/openapi.rb +10 -0
  32. data/lib/apicraft/railtie.rb +11 -0
  33. data/lib/apicraft/version.rb +6 -0
  34. data/lib/apicraft/web/actions.rb +60 -0
  35. data/lib/apicraft/web/app.rb +65 -0
  36. data/lib/apicraft/web/router.rb +62 -0
  37. data/lib/apicraft/web.rb +10 -0
  38. data/lib/apicraft-rails.rb +37 -0
  39. data/web/views/index.html +15 -0
  40. data/web/views/redoc.erb +85 -0
  41. data/web/views/swaggerdoc.erb +38 -0
  42. 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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "middlewares/mocker"
4
+ require_relative "middlewares/introspector"
5
+
6
+ module Apicraft
7
+ # Namespace module for Concerns
8
+ module Middlewares; end
9
+ 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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Apicraft
4
+ module Mocker
5
+ # Generate fake boolean values
6
+ class Boolean < Base
7
+ def mock
8
+ Faker::Boolean.boolean
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Apicraft
4
+ module Mocker
5
+ # Generate fake values for integers
6
+ class Integer < Number
7
+ def mock
8
+ super.to_i
9
+ end
10
+ end
11
+ end
12
+ 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,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Apicraft
4
+ module Mocker
5
+ # Generate mock based on the OneOf schema
6
+ class OneOf < Base
7
+ def mock
8
+ Mocker.mock(
9
+ schema.one_of.sample
10
+ )
11
+ end
12
+ end
13
+ end
14
+ 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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "openapi/response"
4
+ require_relative "openapi/operation"
5
+ require_relative "openapi/contract"
6
+
7
+ module Apicraft
8
+ # Namespace module for OpenAPI
9
+ module Openapi; end
10
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Apicraft
4
+ # Hooks into the application boot process
5
+ # using Rails::Railtie
6
+ class Railtie < Rails::Railtie
7
+ initializer "apicraft.load_api_contracts" do
8
+ Apicraft::Loader.load!
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Current version of Apicraft.
4
+ module Apicraft
5
+ VERSION = "0.5.0.beta1"
6
+ 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