modern 0.4.2

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