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.
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