modern 0.4.2
Sign up to get free protection for your applications and to get access to all the features.
- 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
|