modern 0.4.2
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/.editorconfig +8 -0
- data/.gitignore +10 -0
- data/.rspec +2 -0
- data/.rubocop.yml +173 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +36 -0
- data/Rakefile +10 -0
- data/TODOS.md +4 -0
- data/bin/console +9 -0
- data/bin/setup +8 -0
- data/example/Gemfile +9 -0
- data/example/Gemfile.lock +102 -0
- data/example/config.ru +11 -0
- data/example/example.rb +19 -0
- data/lib/modern/app/error_handling.rb +45 -0
- data/lib/modern/app/request_handling/input_handling.rb +65 -0
- data/lib/modern/app/request_handling/output_handling.rb +54 -0
- data/lib/modern/app/request_handling/request_container.rb +55 -0
- data/lib/modern/app/request_handling.rb +70 -0
- data/lib/modern/app/router.rb +27 -0
- data/lib/modern/app/trie_router.rb +37 -0
- data/lib/modern/app.rb +82 -0
- data/lib/modern/capsule.rb +17 -0
- data/lib/modern/configuration.rb +16 -0
- data/lib/modern/core_ext/array.rb +23 -0
- data/lib/modern/core_ext/hash.rb +17 -0
- data/lib/modern/descriptor/content.rb +14 -0
- data/lib/modern/descriptor/converters/input/base.rb +29 -0
- data/lib/modern/descriptor/converters/input/json.rb +21 -0
- data/lib/modern/descriptor/converters/output/base.rb +25 -0
- data/lib/modern/descriptor/converters/output/json.rb +48 -0
- data/lib/modern/descriptor/converters/output/yaml.rb +21 -0
- data/lib/modern/descriptor/converters.rb +4 -0
- data/lib/modern/descriptor/core.rb +63 -0
- data/lib/modern/descriptor/info.rb +27 -0
- data/lib/modern/descriptor/parameters.rb +149 -0
- data/lib/modern/descriptor/request_body.rb +13 -0
- data/lib/modern/descriptor/response.rb +26 -0
- data/lib/modern/descriptor/route.rb +93 -0
- data/lib/modern/descriptor/security.rb +104 -0
- data/lib/modern/descriptor/server.rb +12 -0
- data/lib/modern/descriptor.rb +15 -0
- data/lib/modern/doc_generator/open_api3/operations.rb +114 -0
- data/lib/modern/doc_generator/open_api3/paths.rb +24 -0
- data/lib/modern/doc_generator/open_api3/schema_default_types.rb +50 -0
- data/lib/modern/doc_generator/open_api3/schemas.rb +171 -0
- data/lib/modern/doc_generator/open_api3/security_schemes.rb +15 -0
- data/lib/modern/doc_generator/open_api3.rb +141 -0
- data/lib/modern/dsl/info.rb +39 -0
- data/lib/modern/dsl/response_builder.rb +41 -0
- data/lib/modern/dsl/root.rb +38 -0
- data/lib/modern/dsl/route_builder.rb +130 -0
- data/lib/modern/dsl/scope.rb +144 -0
- data/lib/modern/dsl/scope_settings.rb +39 -0
- data/lib/modern/dsl.rb +14 -0
- data/lib/modern/errors/error.rb +7 -0
- data/lib/modern/errors/setup_errors.rb +11 -0
- data/lib/modern/errors/web_errors.rb +83 -0
- data/lib/modern/errors.rb +3 -0
- data/lib/modern/redirect.rb +30 -0
- data/lib/modern/request.rb +34 -0
- data/lib/modern/response.rb +39 -0
- data/lib/modern/services.rb +17 -0
- data/lib/modern/struct.rb +25 -0
- data/lib/modern/types.rb +41 -0
- data/lib/modern/util/header_parsing.rb +27 -0
- data/lib/modern/util/trie_node.rb +53 -0
- data/lib/modern/version.rb +6 -0
- data/lib/modern.rb +8 -0
- data/manual/01-why_modern.md +115 -0
- data/modern.gemspec +54 -0
- metadata +439 -0
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'modern/errors/web_errors'
|
4
|
+
|
5
|
+
require "modern/util/header_parsing"
|
6
|
+
|
7
|
+
module Modern
|
8
|
+
class App
|
9
|
+
module RequestHandling
|
10
|
+
module OutputHandling
|
11
|
+
private
|
12
|
+
|
13
|
+
def determine_output_converter(request, route)
|
14
|
+
accept_header = request.env["HTTP_ACCEPT"] || "*/*"
|
15
|
+
|
16
|
+
requested_types =
|
17
|
+
Modern::Util::HeaderParsing.parse_accept_header(accept_header) \
|
18
|
+
.select { |c| route.content_types.any? { |ct| File.fnmatch(c, ct) } }
|
19
|
+
|
20
|
+
ret =
|
21
|
+
route.output_converters.find do |oc|
|
22
|
+
requested_types.find do |c|
|
23
|
+
File.fnmatch(c, oc.media_type)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
raise Errors::NotAcceptableError, "No servable types in Accept header: #{accept_header || 'nil'}" \
|
28
|
+
if ret.nil?
|
29
|
+
|
30
|
+
ret
|
31
|
+
end
|
32
|
+
|
33
|
+
def validate_output!(content, retval, request, route)
|
34
|
+
if content.type.nil?
|
35
|
+
retval
|
36
|
+
else
|
37
|
+
content.type[retval]
|
38
|
+
end
|
39
|
+
rescue Dry::Types::ConstraintError,
|
40
|
+
Dry::Types::MissingKeyError,
|
41
|
+
Dry::Struct::Error => err
|
42
|
+
if @configuration.validate_responses != 'no'
|
43
|
+
request.logger.error "Bad validation for response to #{route.id}, " \
|
44
|
+
"content type #{content.media_type}", err
|
45
|
+
|
46
|
+
raise err if @configuration.validate_responses == 'error'
|
47
|
+
end
|
48
|
+
|
49
|
+
retval
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Modern
|
4
|
+
class App
|
5
|
+
module RequestHandling
|
6
|
+
# Encapsulates all non-derived portions of the request to run security
|
7
|
+
# actions inside of it.
|
8
|
+
class PartialRequestContainer
|
9
|
+
attr_reader :logger
|
10
|
+
attr_reader :configuration
|
11
|
+
attr_reader :services
|
12
|
+
attr_reader :route
|
13
|
+
|
14
|
+
attr_reader :request
|
15
|
+
attr_reader :response
|
16
|
+
|
17
|
+
def initialize(logger, configuration, services, route, request, response)
|
18
|
+
@logger = logger
|
19
|
+
@configuration = configuration
|
20
|
+
@services = services
|
21
|
+
@route = route
|
22
|
+
|
23
|
+
@request = request
|
24
|
+
@response = response
|
25
|
+
end
|
26
|
+
|
27
|
+
def with_logger_fields(fields = {})
|
28
|
+
original_logger = @logger
|
29
|
+
@logger = original_logger.child(fields)
|
30
|
+
|
31
|
+
ret = yield
|
32
|
+
|
33
|
+
@logger = original_logger
|
34
|
+
|
35
|
+
ret
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Encapsulates all portions of the request, including params and body,
|
40
|
+
# to have a route action run inside of it. This will be subclassed by
|
41
|
+
# {Modern::Descriptor::Route}s that incorporate helper libraries.
|
42
|
+
class FullRequestContainer < PartialRequestContainer
|
43
|
+
attr_reader :params
|
44
|
+
attr_reader :body
|
45
|
+
|
46
|
+
def initialize(logger, configuration, services, route, request, response, params, body)
|
47
|
+
super(logger, configuration, services, route, request, response)
|
48
|
+
|
49
|
+
@params = params
|
50
|
+
@body = body
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'modern/errors/web_errors'
|
4
|
+
|
5
|
+
require 'modern/app/request_handling/request_container'
|
6
|
+
require 'modern/app/request_handling/input_handling'
|
7
|
+
require 'modern/app/request_handling/output_handling'
|
8
|
+
|
9
|
+
module Modern
|
10
|
+
class App
|
11
|
+
module RequestHandling
|
12
|
+
include Modern::App::RequestHandling::InputHandling
|
13
|
+
include Modern::App::RequestHandling::OutputHandling
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
# Encapsulates the full request handler for the app, given the route from
|
18
|
+
# the router. Handles security scheme checks, parameter validation, etc.
|
19
|
+
def process_request(request, response, route)
|
20
|
+
route_logger = request.logger.child(id: route.id)
|
21
|
+
|
22
|
+
output_converter = determine_output_converter(request, route)
|
23
|
+
raise Errors::NotAcceptableError \
|
24
|
+
if output_converter.nil? || !route.content_types.include?(output_converter.media_type)
|
25
|
+
|
26
|
+
container = PartialRequestContainer.new(
|
27
|
+
route_logger, @configuration, @services, route, request, response
|
28
|
+
)
|
29
|
+
|
30
|
+
raise Errors::UnauthorizedError \
|
31
|
+
if !route.security.empty? && route.security.all? { |s| s.validate(container) == false }
|
32
|
+
|
33
|
+
params = parse_parameters(request, route)
|
34
|
+
body = parse_request_body(request, route) unless route.request_body.nil?
|
35
|
+
raise Errors::BadRequestError \
|
36
|
+
if body.nil? && route.request_body&.required
|
37
|
+
|
38
|
+
begin
|
39
|
+
# Creates a FullRequestContainer and runs through it
|
40
|
+
container = route.request_container_class.new(
|
41
|
+
route_logger, @configuration, @services, route, request, response, params, body
|
42
|
+
)
|
43
|
+
retval = container.instance_exec(&route.action)
|
44
|
+
|
45
|
+
# Leaving a hole for people to bypass responses and dump whatever
|
46
|
+
# they want through the underlying `Rack::Response`.
|
47
|
+
unless response.bypass
|
48
|
+
route_code = route.responses_by_code.key?(response.status) ? response.status : :default
|
49
|
+
|
50
|
+
route_response = route.responses_by_code[route_code]
|
51
|
+
route_content = route_response.content_by_type[output_converter.media_type]
|
52
|
+
|
53
|
+
if route_content.nil?
|
54
|
+
raise Errors::InternalServiceError,
|
55
|
+
"no content for '#{output_converter.media_type}' for code #{route_code}"
|
56
|
+
end
|
57
|
+
|
58
|
+
retval = validate_output!(route_content, retval, request, route)
|
59
|
+
|
60
|
+
response.headers["Content-Type"] = output_converter.media_type
|
61
|
+
response.write(output_converter.converter.call(route_content.type, retval))
|
62
|
+
end
|
63
|
+
rescue StandardError => err
|
64
|
+
route_logger.error(err)
|
65
|
+
raise
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'modern/descriptor/route'
|
4
|
+
|
5
|
+
module Modern
|
6
|
+
class App
|
7
|
+
class Router < Dry::Struct
|
8
|
+
attribute :routes, Modern::Types::Strict::Array.of(
|
9
|
+
Modern::Types.Instance(Modern::Descriptor::Route)
|
10
|
+
)
|
11
|
+
|
12
|
+
def initialize(inputs)
|
13
|
+
super(inputs)
|
14
|
+
end
|
15
|
+
|
16
|
+
def resolve(_http_method, _path)
|
17
|
+
raise "#{self.class.name}#resolve must be implemented."
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def process_routes
|
23
|
+
raise "#{self.class.name}#process_routes must be implemented."
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'diff/lcs'
|
4
|
+
|
5
|
+
require "modern/app/router"
|
6
|
+
|
7
|
+
require 'modern/util/trie_node'
|
8
|
+
|
9
|
+
module Modern
|
10
|
+
class App
|
11
|
+
class TrieRouter < Router
|
12
|
+
def initialize(inputs)
|
13
|
+
super(inputs)
|
14
|
+
|
15
|
+
@trie = build_trie(routes)
|
16
|
+
end
|
17
|
+
|
18
|
+
def resolve(http_method, path)
|
19
|
+
trie_path = path.sub(%r|^/|, "").split("/") + [http_method.to_s.upcase]
|
20
|
+
@trie.get(trie_path)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def build_trie(routes)
|
26
|
+
trie = Modern::Util::TrieNode.new
|
27
|
+
routes.each do |route|
|
28
|
+
route.path_matcher # pre-seed the path matcher
|
29
|
+
|
30
|
+
trie.add(route.route_tokens + [route.http_method], route, raise_if_present: true)
|
31
|
+
end
|
32
|
+
|
33
|
+
trie
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
data/lib/modern/app.rb
ADDED
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rack"
|
4
|
+
|
5
|
+
require "deep_dup"
|
6
|
+
require "ice_nine"
|
7
|
+
|
8
|
+
require "ougai"
|
9
|
+
|
10
|
+
require "modern/configuration"
|
11
|
+
require "modern/descriptor"
|
12
|
+
require "modern/services"
|
13
|
+
|
14
|
+
require "modern/request"
|
15
|
+
require "modern/response"
|
16
|
+
|
17
|
+
require "modern/app/error_handling"
|
18
|
+
require "modern/app/request_handling"
|
19
|
+
require "modern/app/trie_router"
|
20
|
+
|
21
|
+
require "modern/doc_generator/open_api3"
|
22
|
+
|
23
|
+
require "modern/errors"
|
24
|
+
require "modern/redirect"
|
25
|
+
|
26
|
+
module Modern
|
27
|
+
# `App` is the core of Modern. Some Rack application frameworks have you
|
28
|
+
# inherit from them to generate your application; however, that makes it
|
29
|
+
# pretty difficult to control immutability of the underlying routes. Since we
|
30
|
+
# have a need to generate an OpenAPI specification off of our routes and
|
31
|
+
# our behaviors, this is not an acceptable trade-off. As such, Modern expects
|
32
|
+
# to be passed a {Modern::Description::Descriptor}, which specifies a set of
|
33
|
+
# {Modern::Description::Route}s. The app then dispatches requests based on
|
34
|
+
# these routes.
|
35
|
+
class App
|
36
|
+
include Modern::App::ErrorHandling
|
37
|
+
include Modern::App::RequestHandling
|
38
|
+
|
39
|
+
attr_reader :logger
|
40
|
+
attr_reader :services
|
41
|
+
|
42
|
+
def initialize(descriptor, configuration = Modern::Configuration.new, services = Services.new)
|
43
|
+
@descriptor = IceNine.deep_freeze(DeepDup.deep_dup(descriptor))
|
44
|
+
@configuration = IceNine.deep_freeze(DeepDup.deep_dup(configuration))
|
45
|
+
@services = services
|
46
|
+
|
47
|
+
# TODO: figure out a good componentized naming scheme for Modern's own logs
|
48
|
+
# so as to clearly differentiate them from user logs.
|
49
|
+
@logger = @services.base_logger
|
50
|
+
|
51
|
+
@router = Modern::App::TrieRouter.new(
|
52
|
+
routes: Modern::DocGenerator::OpenAPI3.new.decorate_with_openapi_routes(@configuration, @descriptor)
|
53
|
+
)
|
54
|
+
end
|
55
|
+
|
56
|
+
def call(env)
|
57
|
+
request = Modern::Request.new(env, logger)
|
58
|
+
response = Modern::Response.new(request)
|
59
|
+
response.headers["X-Request-Id"] = request.request_id
|
60
|
+
|
61
|
+
route = @router.resolve(request.request_method, request.path_info)
|
62
|
+
|
63
|
+
begin
|
64
|
+
raise Modern::Errors::NotFoundError if route.nil?
|
65
|
+
|
66
|
+
process_request(request, response, route)
|
67
|
+
response.finish
|
68
|
+
rescue Modern::Redirect => redirect
|
69
|
+
response.redirect(redirect.redirect_to, redirect.status)
|
70
|
+
rescue Modern::Errors::WebError => err
|
71
|
+
catch_web_error(response, err)
|
72
|
+
rescue StandardError => err
|
73
|
+
catch_unhandled_error(response, err)
|
74
|
+
ensure
|
75
|
+
response.finish
|
76
|
+
request.cleanup
|
77
|
+
end
|
78
|
+
|
79
|
+
response.to_a
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'modern/struct'
|
4
|
+
|
5
|
+
module Modern
|
6
|
+
class Configuration < Modern::Struct
|
7
|
+
# TODO: once Modern is done, figure out sane defaults.
|
8
|
+
attribute :show_errors, Modern::Types::Strict::Bool.default(true)
|
9
|
+
attribute :log_input_converter_errors, Modern::Types::Strict::Bool.default(true)
|
10
|
+
|
11
|
+
attribute :validate_responses, Modern::Types::Strict::String.default("log").enum("no", "log", "error")
|
12
|
+
|
13
|
+
attribute :open_api_json_path, Modern::Types::Strict::String.default("/openapi.json")
|
14
|
+
attribute :open_api_yaml_path, Modern::Types::Strict::String.default("/openapi.yaml")
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Array
|
4
|
+
def subsequences
|
5
|
+
(0..length).map do |n|
|
6
|
+
self[0, n]
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
def duplicates
|
11
|
+
group_by(&:itself).map { |e| e[0] if e[1][1] }.compact
|
12
|
+
end
|
13
|
+
|
14
|
+
def deep_compact!
|
15
|
+
each do |v|
|
16
|
+
if v.is_a?(Hash) || v.respond_to?(:values)
|
17
|
+
v.values.deep_compact!
|
18
|
+
elsif v.is_a?(Array) || v.respond_to?(:each)
|
19
|
+
v.deep_compact!
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Hash
|
4
|
+
def deep_compact!
|
5
|
+
compact!
|
6
|
+
|
7
|
+
each_value do |v|
|
8
|
+
if v.is_a?(Hash) || v.respond_to?(:values)
|
9
|
+
v.deep_compact!
|
10
|
+
elsif v.is_a?(Array) || v.respond_to?(:each)
|
11
|
+
v.deep_compact!
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
self
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "modern/struct"
|
4
|
+
|
5
|
+
module Modern
|
6
|
+
module Descriptor
|
7
|
+
class Content < Modern::Struct
|
8
|
+
Type = Types.Instance(self)
|
9
|
+
|
10
|
+
attribute :media_type, Types::MIMEType
|
11
|
+
attribute :type, (Types::Type | Types::Struct).optional.default(nil)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'content-type'
|
4
|
+
|
5
|
+
module Modern
|
6
|
+
module Descriptor
|
7
|
+
module Converters
|
8
|
+
module Input
|
9
|
+
# An input converter takes a raw HTTP request body (as a `StringIO`) and
|
10
|
+
# returns a Ruby object. A JSON converter would return a hash, for
|
11
|
+
# example; a converter for 'image/* might return the `StringIO` object
|
12
|
+
# without alteration. The results of this converter will be passed into
|
13
|
+
# against a {Modern::Types::Type} if one has been provided (which will
|
14
|
+
# cause a validation check) before being passed into the route action.
|
15
|
+
class Base < Modern::Struct
|
16
|
+
attr_reader :content_type
|
17
|
+
|
18
|
+
def initialize(fields)
|
19
|
+
super
|
20
|
+
@content_type = ContentType.parse(media_type).freeze
|
21
|
+
end
|
22
|
+
|
23
|
+
attribute :media_type, Types::MIMEType
|
24
|
+
attribute :converter, Types.Instance(Proc)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'modern/descriptor/converters/input/base'
|
4
|
+
|
5
|
+
require 'json'
|
6
|
+
|
7
|
+
module Modern
|
8
|
+
module Descriptor
|
9
|
+
module Converters
|
10
|
+
module Input
|
11
|
+
JSON = Base.new(
|
12
|
+
media_type: "application/json",
|
13
|
+
converter: proc do |io|
|
14
|
+
str = io.read
|
15
|
+
str.empty? ? nil : ::JSON.parse(str)
|
16
|
+
end
|
17
|
+
)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Modern
|
4
|
+
module Descriptor
|
5
|
+
module Converters
|
6
|
+
module Output
|
7
|
+
# An output converter takes a Ruby object and returns the raw contents of
|
8
|
+
# an HTTP response body. A JSON converter would invoke `JSON.generate` on
|
9
|
+
# a Ruby object to yield UTF-8 text; a binary converter would take an IO
|
10
|
+
# and dump its contents into the HTTP stream.
|
11
|
+
class Base < Modern::Struct
|
12
|
+
attr_reader :content_type
|
13
|
+
|
14
|
+
def initialize(fields)
|
15
|
+
super
|
16
|
+
@content_type = ContentType.parse(media_type).freeze
|
17
|
+
end
|
18
|
+
|
19
|
+
attribute :media_type, Types::MIMEType
|
20
|
+
attribute :converter, Types.Instance(Proc)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'modern/descriptor/converters/output/base'
|
4
|
+
|
5
|
+
require 'json'
|
6
|
+
|
7
|
+
# it gets confused by the blank line after the retval reassignment.
|
8
|
+
# rubocop:disable Layout/EmptyLinesAroundArguments
|
9
|
+
|
10
|
+
module Modern
|
11
|
+
module Descriptor
|
12
|
+
module Converters
|
13
|
+
module Output
|
14
|
+
JSON = Base.new(
|
15
|
+
media_type: "application/json",
|
16
|
+
converter: proc do |_type, retval|
|
17
|
+
retval =
|
18
|
+
if retval.is_a?(Hash)
|
19
|
+
retval.compact
|
20
|
+
elsif retval.is_a?(Dry::Struct)
|
21
|
+
retval.to_h.compact
|
22
|
+
else
|
23
|
+
retval
|
24
|
+
end
|
25
|
+
|
26
|
+
if retval.respond_to?(:as_json)
|
27
|
+
::JSON.generate(retval.as_json)
|
28
|
+
elsif retval.respond_to?(:to_json)
|
29
|
+
retval.to_json
|
30
|
+
else
|
31
|
+
::JSON.generate(retval)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
)
|
35
|
+
|
36
|
+
# We use this because we pre-bake the OpenAPI3 spec JSON and
|
37
|
+
# want the content type. However, our route invokes
|
38
|
+
# {Modern::Response#bypass!}, so this will never be called.
|
39
|
+
JSONBypass = Base.new(
|
40
|
+
media_type: "application/json",
|
41
|
+
converter: proc { raise "this should never be called!" }
|
42
|
+
)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# rubocop:enable Layout/EmptyLinesAroundArguments
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'modern/descriptor/converters/output/base'
|
4
|
+
|
5
|
+
module Modern
|
6
|
+
module Descriptor
|
7
|
+
module Converters
|
8
|
+
module Output
|
9
|
+
# We use this because we pre-bake the OpenAPI3 spec JSON and
|
10
|
+
# want the content type. However, our route invokes
|
11
|
+
# {Modern::Response#bypass!}, so this will never be called.
|
12
|
+
YAMLBypass = Base.new(
|
13
|
+
media_type: "application/yaml",
|
14
|
+
converter: proc { raise "this should never be called!" }
|
15
|
+
)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# rubocop:enable Layout/EmptyLinesAroundArguments
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "set"
|
4
|
+
|
5
|
+
require "modern/struct"
|
6
|
+
|
7
|
+
require "modern/core_ext/array"
|
8
|
+
|
9
|
+
require "modern/descriptor/info"
|
10
|
+
require "modern/descriptor/server"
|
11
|
+
require "modern/descriptor/converters"
|
12
|
+
require "modern/descriptor/route"
|
13
|
+
|
14
|
+
module Modern
|
15
|
+
module Descriptor
|
16
|
+
# The class that encapsulates all routes, along with their configuration and
|
17
|
+
# metadata. This class can recursively include itself to mount other
|
18
|
+
# instances inside of itself; this is used by {Modern::App} to generate
|
19
|
+
# OpenAPI documentation and design routing accordingly.
|
20
|
+
class Core < Modern::Struct
|
21
|
+
attribute :info, Modern::Descriptor::Info
|
22
|
+
attribute :servers, Types.array_of(Server)
|
23
|
+
|
24
|
+
attribute :routes, Types.array_of(Modern::Descriptor::Route)
|
25
|
+
|
26
|
+
attr_reader :securities_by_name
|
27
|
+
attr_reader :root_schemas
|
28
|
+
attr_reader :routes_by_id
|
29
|
+
attr_reader :routes_by_path
|
30
|
+
|
31
|
+
def initialize(fields)
|
32
|
+
super
|
33
|
+
|
34
|
+
securities = routes.map(&:security).flatten.uniq
|
35
|
+
duplicate_names = securities.map(&:name).duplicates
|
36
|
+
|
37
|
+
raise "Duplicate but not identical securities by names: #{duplicate_names.join(', ')}" \
|
38
|
+
unless duplicate_names.empty?
|
39
|
+
|
40
|
+
@securities_by_name = securities.map { |s| [s.name, s] }.to_h.freeze
|
41
|
+
|
42
|
+
# This could be a set, but I like being able to just pull values in debug and this is
|
43
|
+
# only iterated over.
|
44
|
+
@root_schemas =
|
45
|
+
routes.map do |route|
|
46
|
+
[
|
47
|
+
route.request_body&.type,
|
48
|
+
route.responses.map(&:content).flatten.map(&:type)
|
49
|
+
]
|
50
|
+
end.flatten.compact.uniq.freeze
|
51
|
+
|
52
|
+
@routes_by_path = {}
|
53
|
+
routes.each do |route|
|
54
|
+
@routes_by_path[route.path] ||= {}
|
55
|
+
@routes_by_path[route.path][route.http_method] = route
|
56
|
+
end
|
57
|
+
@routes_by_path.freeze
|
58
|
+
|
59
|
+
@routes_by_id = routes.map { |route| [route.id, route] }.to_h.freeze
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "modern/struct"
|
4
|
+
|
5
|
+
module Modern
|
6
|
+
module Descriptor
|
7
|
+
class Info < Modern::Struct
|
8
|
+
class Contact < Modern::Struct
|
9
|
+
attribute :name, Types::Strict::String.optional.default(nil)
|
10
|
+
attribute :url, Types::Strict::String.optional.default(nil)
|
11
|
+
attribute :email, Types::Strict::String.optional.default(nil)
|
12
|
+
end
|
13
|
+
|
14
|
+
class License < Modern::Struct
|
15
|
+
attribute :name, Types::Strict::String
|
16
|
+
attribute :url, Types::Strict::String.optional.default(nil)
|
17
|
+
end
|
18
|
+
|
19
|
+
attribute :title, Types::Strict::String
|
20
|
+
attribute :description, Types::Strict::String.optional.default(nil)
|
21
|
+
attribute :terms_of_service, Types::Strict::String.optional.default(nil)
|
22
|
+
attribute :contact, Contact.optional.default(nil)
|
23
|
+
attribute :license, License.optional.default(nil)
|
24
|
+
attribute :version, Types::Strict::String
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|