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,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