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,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'modern/struct'
4
+
5
+ require 'modern/capsule'
6
+ require 'modern/descriptor'
7
+
8
+ require 'modern/dsl/scope_settings'
9
+
10
+ require 'deep_dup'
11
+ require 'docile'
12
+ require 'ice_nine'
13
+
14
+ module Modern
15
+ module DSL
16
+ class Scope
17
+ attr_reader :settings
18
+ attr_reader :descriptor
19
+
20
+ def initialize(descriptor, settings = nil)
21
+ @descriptor = descriptor
22
+ @settings = settings&.dup || ScopeSettings.new
23
+ end
24
+
25
+ def capsule(cap)
26
+ raise "Must be a Modern::Capsule." unless cap.is_a?(Modern::Capsule)
27
+ @descriptor = _scope({}, &cap.block)
28
+ end
29
+
30
+ def path(p, &block)
31
+ p_segs = p.split("/")
32
+ new_path_segments = ([@settings.path_segments] + p_segs).flatten
33
+ @descriptor = _scope(path_segments: new_path_segments, &block)
34
+ end
35
+
36
+ def default_response(&block)
37
+ resp = ResponseBuilder.evaluate(@settings.default_response, &block)
38
+ @settings = @settings.copy(default_response: resp)
39
+ end
40
+
41
+ def deprecate!
42
+ @settings = @settings.copy(deprecated: true)
43
+ end
44
+
45
+ def tag(t)
46
+ @settings = @settings.copy(tags: @settings.tags + [t.to_s])
47
+ end
48
+
49
+ def helper(h)
50
+ @settings = @settings.copy(helpers: @settings.helpers + [h])
51
+ end
52
+
53
+ def parameter(name, parameter_type, opts)
54
+ param = Modern::Descriptor::Parameters.from_inputs(name, parameter_type, opts)
55
+ raise "Duplicate parameter '#{name}'.'" if @settings.parameters.any? { |p| p.name == param.name }
56
+
57
+ @settings = @settings.copy(parameters: @settings.parameters + [param])
58
+ end
59
+
60
+ def clear_security!
61
+ @settings = @settings.copy(security: [])
62
+ end
63
+
64
+ def security(sec)
65
+ @settings = @settings.copy(security: @settings.security + [sec])
66
+ end
67
+
68
+ def input_converter(media_type_or_converter, &block)
69
+ if media_type_or_converter.is_a?(Modern::Descriptor::Converters::Input::Base)
70
+ @settings = @settings.copy(input_converters: @settings.input_converters + [media_type_or_converter])
71
+ elsif media_type_or_converter.is_a?(String) && !block.nil?
72
+ input_converter(
73
+ Modern::Descriptor::Converters::Input::Base.new(
74
+ media_type: media_type_or_converter, converter: block
75
+ )
76
+ )
77
+ else
78
+ raise "must pass a String and block or a Modern::Descriptor::Converters::Input::Base."
79
+ end
80
+ end
81
+
82
+ def clear_input_converters!
83
+ @settings = @settings.copy(input_converters: [])
84
+ end
85
+
86
+ def output_converter(media_type_or_converter, &block)
87
+ if media_type_or_converter.is_a?(Modern::Descriptor::Converters::Output::Base)
88
+ @settings = @settings.copy(output_converters: @settings.output_converters + [media_type_or_converter])
89
+ elsif media_type_or_converter.is_a?(String) && !block.nil?
90
+ output_converter(
91
+ Modern::Descriptor::Converters::Output::Base.new(
92
+ media_type: media_type_or_converter, converter: block
93
+ )
94
+ )
95
+ else
96
+ raise "must pass a String and block or a Modern::Descriptor::Converters::Output::Base."
97
+ end
98
+ end
99
+
100
+ def clear_output_converters!
101
+ @settings = @settings.copy(output_converters: [])
102
+ end
103
+
104
+ def route(id, http_method, path = nil, &block)
105
+ route = RouteBuilder.evaluate(id, http_method, path, @settings.dup, &block)
106
+ @descriptor = @descriptor.copy(routes: @descriptor.routes + [route])
107
+ end
108
+
109
+ def get(id, path = nil, &block)
110
+ route(id, :get, path, &block)
111
+ end
112
+
113
+ def post(id, path = nil, &block)
114
+ route(id, :post, path, &block)
115
+ end
116
+
117
+ def put(id, path = nil, &block)
118
+ route(id, :put, path, &block)
119
+ end
120
+
121
+ def delete(id, path = nil, &block)
122
+ route(id, :delete, path, &block)
123
+ end
124
+
125
+ def patch(id, path = nil, &block)
126
+ route(id, :patch, path, &block)
127
+ end
128
+
129
+ def self.evaluate(descriptor, settings, &block)
130
+ scope = Scope.new(descriptor, settings)
131
+ scope.instance_exec(&block)
132
+
133
+ scope.descriptor
134
+ end
135
+
136
+ private
137
+
138
+ def _scope(new_settings = {}, &block)
139
+ ret = Scope.evaluate(descriptor, @settings.copy(new_settings), &block)
140
+ ret
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'modern/struct'
4
+
5
+ require 'modern/capsule'
6
+ require 'modern/descriptor'
7
+
8
+ require 'deep_dup'
9
+ require 'docile'
10
+ require 'ice_nine'
11
+
12
+ module Modern
13
+ module DSL
14
+ class ScopeSettings < Modern::Struct
15
+ attribute :path_segments, Types.array_of(
16
+ Types::Strict::String.constrained(
17
+ format: %r,[^/]+,
18
+ )
19
+ )
20
+
21
+ attribute :tags, Types.array_of(Types::Strict::String)
22
+
23
+ attribute :deprecated, Types::Strict::Bool.default(false)
24
+
25
+ attribute :parameters, Types.array_of(Modern::Descriptor::Parameters::Base)
26
+
27
+ attribute :default_response, Modern::Descriptor::Response.optional.default(
28
+ Modern::Descriptor::Response.new(http_code: :default)
29
+ )
30
+
31
+ # TODO: this code gets way less gross when we get Types.Map
32
+ attribute :input_converters, Types.array_of(Modern::Descriptor::Converters::Input::Base)
33
+ attribute :output_converters, Types.array_of(Modern::Descriptor::Converters::Output::Base)
34
+
35
+ attribute :security, Types.array_of(Modern::Descriptor::Security::Base)
36
+ attribute :helpers, Types.array_of(Types.Instance(Module))
37
+ end
38
+ end
39
+ end
data/lib/modern/dsl.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'modern/descriptor'
4
+
5
+ # TODO: make the DSL in general more efficient
6
+ # The various copies in every class in this DSL builder are probably
7
+ # unavoidable. Which is a bummer. But we could probably reduce the amount
8
+ # of code involved. I've been trying to think of a good way to make a
9
+ # generalized builder for Dry::Struct; anybody have a good idea?
10
+ # TODO: Figure out why Docile (hence removed) causes settings leaks
11
+ # For SOME awful reason, `@settings` in sub-scopes is leaking out to
12
+ # parent scopes. I have only isolated this down to Docile, as when I use
13
+ # `instance_exec` it doesn't happen.
14
+ Dir["#{__dir__}/dsl/**/*.rb"].each { |f| require_relative f }
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Modern
4
+ module Errors
5
+ class Error < RuntimeError; end
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "modern/errors/error"
4
+
5
+ module Modern
6
+ module Errors
7
+ class SetupError < Modern::Errors::Error; end
8
+
9
+ class RoutingError < SetupError; end
10
+ end
11
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "modern/errors/error"
4
+
5
+ module Modern
6
+ module Errors
7
+ class WebError < Modern::Errors::Error
8
+ def status
9
+ raise "#{self.class.name}#status must be implemented."
10
+ end
11
+ end
12
+
13
+ class BadRequestError < WebError
14
+ def initialize(msg = "Bad request")
15
+ super(msg)
16
+ end
17
+
18
+ def status
19
+ 400
20
+ end
21
+ end
22
+
23
+ class UnauthorizedError < WebError
24
+ def initialize(msg = "Unauthorized")
25
+ super(msg)
26
+ end
27
+
28
+ def status
29
+ 401
30
+ end
31
+ end
32
+
33
+ class ForbiddenError < WebError
34
+ def initialize(msg = "Forbidden")
35
+ super(msg)
36
+ end
37
+
38
+ def status
39
+ 403
40
+ end
41
+ end
42
+
43
+ class NotFoundError < WebError
44
+ def initialize(msg = "Not found")
45
+ super(msg)
46
+ end
47
+
48
+ def status
49
+ 404
50
+ end
51
+ end
52
+
53
+ class NotAcceptableError < WebError
54
+ def initialize(msg = "Not acceptable (no servable content types in Accept header)")
55
+ super(msg)
56
+ end
57
+
58
+ def status
59
+ 406
60
+ end
61
+ end
62
+
63
+ class UnsupportedMediaTypeError < WebError
64
+ def initialize(msg = "Unrecognized request Content-Type.")
65
+ super(msg)
66
+ end
67
+
68
+ def status
69
+ 415
70
+ end
71
+ end
72
+
73
+ class UnprocessableEntity < WebError
74
+ def initialize(msg = "Recognized content-type of body, but could not parse it.")
75
+ super(msg)
76
+ end
77
+
78
+ def status
79
+ 422
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir["#{__dir__}/errors/*.rb"].each { |f| require_relative f }
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Modern
4
+ # rubocop:disable Lint/InheritException
5
+ class Redirect < Exception
6
+ attr_reader :redirect_to
7
+
8
+ def initialize(redirect_to)
9
+ raise "Redirects require a target." if redirect_to.nil?
10
+ @redirect_to = redirect_to
11
+ end
12
+
13
+ def status
14
+ raise "#{self.class.name}#status must be implemented."
15
+ end
16
+ end
17
+ # rubocop:enable Lint/InheritException
18
+
19
+ class PermanentRedirect < Redirect
20
+ def status
21
+ 308
22
+ end
23
+ end
24
+
25
+ class TemporaryRedirect < Redirect
26
+ def status
27
+ 307
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack'
4
+ require 'securerandom'
5
+
6
+ module Modern
7
+ class Request < Rack::Request
8
+ # rubocop:disable Style/MutableConstant
9
+ LOCAL_REQUEST_STORE = {}
10
+ # rubocop:enable Style/MutableConstant
11
+
12
+ attr_reader :logger
13
+
14
+ def initialize(env, logger)
15
+ super(env)
16
+
17
+ env["HTTP_X_REQUEST_ID"] ||= SecureRandom.uuid
18
+
19
+ @logger = logger.child(request_id: request_id)
20
+ end
21
+
22
+ def request_id
23
+ env["HTTP_X_REQUEST_ID"]
24
+ end
25
+
26
+ def local_store
27
+ LOCAL_REQUEST_STORE[request_id] ||= {}
28
+ end
29
+
30
+ def cleanup
31
+ LOCAL_REQUEST_STORE.delete(request_id)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack'
4
+
5
+ require 'json'
6
+
7
+ module Modern
8
+ class Response < Rack::Response
9
+ attr_reader :request
10
+ attr_reader :bypass
11
+
12
+ def initialize(request, body = [], status = 200, header = {})
13
+ super(body, status, header)
14
+
15
+ @request = request
16
+ @bypass = false
17
+ end
18
+
19
+ def bypass!
20
+ @bypass = true
21
+ end
22
+
23
+ def json(object, pretty: false)
24
+ headers["Content-Type"] = "application/json"
25
+
26
+ if pretty
27
+ write(JSON.pretty_generate(object))
28
+ else
29
+ write(JSON.generate(object))
30
+ end
31
+ end
32
+
33
+ def text(object)
34
+ headers["Content-Type"] = "text/plain"
35
+
36
+ write(object.to_s)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "modern/struct"
4
+
5
+ module Modern
6
+ # The default services catalogue for a Modern app, and one that can be
7
+ # extended by a consuming application to add additional services. Mixins
8
+ # and multiple services from multiple packages can be done with `dry-struct`
9
+ # but looks a little bizarre:
10
+ #
11
+ # https://discourse.dry-rb.org/t/dry-struct-reusing-a-set-of-common-attributes/315/3
12
+ class Services < Modern::Struct
13
+ LoggerType = Types.Instance(Ougai::Logger) | Types.Instance(Ougai::ChildLogger)
14
+
15
+ attribute :base_logger, (LoggerType.default { Ougai::Logger.new($stderr) })
16
+ end
17
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'deep_merge/rails_compat'
4
+ require 'dry/struct'
5
+
6
+ require 'modern/types'
7
+
8
+ module Modern
9
+ class Struct < Dry::Struct
10
+ module Copy
11
+ # This implementation is necessary because the "fast" way (hash, merge, recreate)
12
+ # WILL EAT YOUR TYPE DATA. This is the only way I can find to copy-but-change an
13
+ # object that doesn't.
14
+ #
15
+ # Computers are bad.
16
+ def copy(fields = {})
17
+ self.class[self.class.attribute_names.map { |n| [n, self[n]] }.to_h.merge(fields)]
18
+ end
19
+ end
20
+
21
+ constructor_type :strict_with_defaults
22
+
23
+ include Copy
24
+ end
25
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/types'
4
+ require 'dry/struct'
5
+
6
+ require 'ice_nine'
7
+
8
+ module Modern
9
+ module Types
10
+ include Dry::Types.module
11
+
12
+ # rubocop:disable Style/MutableConstant
13
+ # This is left unfrozen so as to allow additional verbs to be added
14
+ # in the future. Should be rare, but I've seen it done...
15
+ HTTP_METHODS = %w[GET POST PUT DELETE PATCH HEAD OPTIONS TRACE]
16
+ # rubocop:enable Style/MutableConstant
17
+
18
+ Type = Instance(Dry::Types::Type)
19
+ Struct = Instance(Dry::Struct)
20
+
21
+ HttpMethod = Types::Coercible::String.enum(*HTTP_METHODS)
22
+ HttpPath = Types::Strict::String.constrained(
23
+ format: %r,/.*,
24
+ )
25
+
26
+ MIMEType = Types::Strict::String.constrained(
27
+ format: %r,\w+/[-.\w]+(?:\+[-.\w]+)?,
28
+ )
29
+
30
+ RouteAction = Instance(Proc)
31
+ SecurityAction = Instance(Proc)
32
+
33
+ ParameterStyle = Types::Coercible::String.enum(:matrix, :label, :form,
34
+ :simple, :space_delimited,
35
+ :pipe_delimited, :deep_object)
36
+
37
+ def self.array_of(type)
38
+ Modern::Types::Strict::Array.of(type).default([])
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Modern
4
+ module Util
5
+ module HeaderParsing
6
+ def self.parse_accept_header(value)
7
+ # TODO: this is probably more garbage creation than necessary.
8
+ # TODO: may poorly prioritize specificity, i.e. `text/*` over `*/*`
9
+ # TODO: this doesn't support `;level=`, but should we bother?
10
+ value.split(",").map do |type_declaration|
11
+ tuple = type_declaration.strip.split(";q=")
12
+ tuple[1] = tuple[1]&.to_f || 1.0
13
+
14
+ tuple
15
+ end.sort do |a, b|
16
+ comp = a.last <=> b.last
17
+
18
+ if comp != 0
19
+ comp
20
+ else
21
+ -(a.first <=> b.first)
22
+ end
23
+ end.map(&:first)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "modern/errors/setup_errors"
4
+
5
+ module Modern
6
+ module Util
7
+ class TrieNode
8
+ attr_reader :parent
9
+ attr_reader :path
10
+ attr_accessor :value
11
+
12
+ attr_reader :children
13
+
14
+ def initialize(path = [])
15
+ @path = path
16
+ @children = {}
17
+ end
18
+
19
+ def add(key, value, raise_if_present: false)
20
+ key = [key].flatten
21
+
22
+ if key.empty?
23
+ if @value
24
+ raise Modern::Errors::RoutingError, "Existing value at #{path.inspect}: #{@value}" \
25
+ if raise_if_present
26
+ end
27
+
28
+ @value = value
29
+ else
30
+ child_name = key.first
31
+ @children[child_name] ||= TrieNode.new(path + [child_name])
32
+
33
+ @children[child_name].add(key[1..-1], value, raise_if_present: raise_if_present)
34
+ end
35
+ end
36
+
37
+ def [](child_name)
38
+ @children[child_name] || @children[:templated]
39
+ end
40
+
41
+ def get(key = [])
42
+ key = [key].flatten
43
+
44
+ node = self
45
+ until key.empty? || node.nil?
46
+ node = node[key.shift]
47
+ end
48
+
49
+ node&.value
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Modern
4
+ VERSION = "0.4.2"
5
+ OPENAPI_VERSION = "3.0.1"
6
+ end
data/lib/modern.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "modern/version"
4
+
5
+ require "modern/app"
6
+
7
+ module Modern
8
+ end