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.
Files changed (75) hide show
  1. checksums.yaml +7 -0
  2. data/.editorconfig +8 -0
  3. data/.gitignore +10 -0
  4. data/.rspec +2 -0
  5. data/.rubocop.yml +173 -0
  6. data/CODE_OF_CONDUCT.md +74 -0
  7. data/Gemfile +10 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +36 -0
  10. data/Rakefile +10 -0
  11. data/TODOS.md +4 -0
  12. data/bin/console +9 -0
  13. data/bin/setup +8 -0
  14. data/example/Gemfile +9 -0
  15. data/example/Gemfile.lock +102 -0
  16. data/example/config.ru +11 -0
  17. data/example/example.rb +19 -0
  18. data/lib/modern/app/error_handling.rb +45 -0
  19. data/lib/modern/app/request_handling/input_handling.rb +65 -0
  20. data/lib/modern/app/request_handling/output_handling.rb +54 -0
  21. data/lib/modern/app/request_handling/request_container.rb +55 -0
  22. data/lib/modern/app/request_handling.rb +70 -0
  23. data/lib/modern/app/router.rb +27 -0
  24. data/lib/modern/app/trie_router.rb +37 -0
  25. data/lib/modern/app.rb +82 -0
  26. data/lib/modern/capsule.rb +17 -0
  27. data/lib/modern/configuration.rb +16 -0
  28. data/lib/modern/core_ext/array.rb +23 -0
  29. data/lib/modern/core_ext/hash.rb +17 -0
  30. data/lib/modern/descriptor/content.rb +14 -0
  31. data/lib/modern/descriptor/converters/input/base.rb +29 -0
  32. data/lib/modern/descriptor/converters/input/json.rb +21 -0
  33. data/lib/modern/descriptor/converters/output/base.rb +25 -0
  34. data/lib/modern/descriptor/converters/output/json.rb +48 -0
  35. data/lib/modern/descriptor/converters/output/yaml.rb +21 -0
  36. data/lib/modern/descriptor/converters.rb +4 -0
  37. data/lib/modern/descriptor/core.rb +63 -0
  38. data/lib/modern/descriptor/info.rb +27 -0
  39. data/lib/modern/descriptor/parameters.rb +149 -0
  40. data/lib/modern/descriptor/request_body.rb +13 -0
  41. data/lib/modern/descriptor/response.rb +26 -0
  42. data/lib/modern/descriptor/route.rb +93 -0
  43. data/lib/modern/descriptor/security.rb +104 -0
  44. data/lib/modern/descriptor/server.rb +12 -0
  45. data/lib/modern/descriptor.rb +15 -0
  46. data/lib/modern/doc_generator/open_api3/operations.rb +114 -0
  47. data/lib/modern/doc_generator/open_api3/paths.rb +24 -0
  48. data/lib/modern/doc_generator/open_api3/schema_default_types.rb +50 -0
  49. data/lib/modern/doc_generator/open_api3/schemas.rb +171 -0
  50. data/lib/modern/doc_generator/open_api3/security_schemes.rb +15 -0
  51. data/lib/modern/doc_generator/open_api3.rb +141 -0
  52. data/lib/modern/dsl/info.rb +39 -0
  53. data/lib/modern/dsl/response_builder.rb +41 -0
  54. data/lib/modern/dsl/root.rb +38 -0
  55. data/lib/modern/dsl/route_builder.rb +130 -0
  56. data/lib/modern/dsl/scope.rb +144 -0
  57. data/lib/modern/dsl/scope_settings.rb +39 -0
  58. data/lib/modern/dsl.rb +14 -0
  59. data/lib/modern/errors/error.rb +7 -0
  60. data/lib/modern/errors/setup_errors.rb +11 -0
  61. data/lib/modern/errors/web_errors.rb +83 -0
  62. data/lib/modern/errors.rb +3 -0
  63. data/lib/modern/redirect.rb +30 -0
  64. data/lib/modern/request.rb +34 -0
  65. data/lib/modern/response.rb +39 -0
  66. data/lib/modern/services.rb +17 -0
  67. data/lib/modern/struct.rb +25 -0
  68. data/lib/modern/types.rb +41 -0
  69. data/lib/modern/util/header_parsing.rb +27 -0
  70. data/lib/modern/util/trie_node.rb +53 -0
  71. data/lib/modern/version.rb +6 -0
  72. data/lib/modern.rb +8 -0
  73. data/manual/01-why_modern.md +115 -0
  74. data/modern.gemspec +54 -0
  75. 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,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'docile'
4
+
5
+ module Modern
6
+ class Capsule
7
+ attr_reader :block
8
+
9
+ def initialize(&block)
10
+ @block = block
11
+ end
12
+
13
+ def self.define(&block)
14
+ Capsule.new(&block)
15
+ end
16
+ end
17
+ 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,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir["#{__dir__}/converters/input/**/*.rb"].each { |f| require_relative f }
4
+ Dir["#{__dir__}/converters/output/**/*.rb"].each { |f| require_relative f }
@@ -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