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,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './schema_default_types'
4
+
5
+ module Modern
6
+ module DocGenerator
7
+ class OpenAPI3
8
+ module Schemas
9
+ # TODO: make all this not awful!
10
+ # I am not a type theorist. I am also not a compiler writer
11
+ # (though I've pretended to be in my day once or twice). dry-types
12
+ # is a very dense language and parsing it to emit OpenAPI schemas is
13
+ # really, really hard for me. This is a brute-force approach. There
14
+ # is probably a better one. My approach is basically to allow for
15
+ # the registration of literal types (which serve as my terminals) and
16
+ # try to build rules on top of those literal types for more complex
17
+ # ideas.
18
+ # TODO: parse the dry-logic in predicates to properly fill out the rest of
19
+ # the JSON schema
20
+
21
+ include SchemaDefaultTypes
22
+
23
+ def register_literal_type(type, oapi3_value)
24
+ raise "`type` must be a Dry::Types::Type." unless type.is_a?(Dry::Types::Type)
25
+
26
+ @type_registry[type] = oapi3_value
27
+ end
28
+
29
+ # Only Dry::Struct
30
+ def _struct_schemas(descriptor)
31
+ ret = {}
32
+ name_to_class = {}
33
+
34
+ descriptor.root_schemas \
35
+ .select { |type_or_structclass| type_or_structclass.is_a?(Class) } \
36
+ .each do |structclass|
37
+ _build_struct(ret, name_to_class, structclass)
38
+ end
39
+
40
+ ret
41
+ end
42
+
43
+ def _build_struct(ret, name_to_class, structclass)
44
+ # TODO: allow overriding the name of the struct in #/components/schemas
45
+ # This is actually trickier than it looks, because we need to also
46
+ # make it referenceable in responses/contents. It probably means an
47
+ # indirect mapping of classes to names and back again.
48
+
49
+ raise "not actually a Dry::Struct class" \
50
+ unless structclass.ancestors.include?(Dry::Struct)
51
+
52
+ name = structclass.name.split("::").last
53
+
54
+ if name_to_class[name] == structclass
55
+ name
56
+ else
57
+ if !name_to_class[name].nil?
58
+ raise "Duplicate schema name: '#{name}'. Only one class, regardless " \
59
+ "of namespace, can be called this."
60
+ end
61
+
62
+ ret[name] = _build_object_from_schema(ret, name_to_class, structclass.schema)
63
+ end
64
+
65
+ name # necessary for recursive calls in _build_schema_value
66
+ end
67
+
68
+ def _build_object_from_schema(ret, name_to_class, dt_schema)
69
+ {
70
+ type: "object",
71
+ properties: dt_schema.map do |k, v|
72
+ [k, _build_schema_value(ret, name_to_class, v)]
73
+ end.to_h
74
+ }
75
+ end
76
+
77
+ def _struct_ref(structclass)
78
+ { "$ref": "#/components/schemas/#{structclass.name.split('::').last}" }
79
+ end
80
+
81
+ def _build_schema_value(ret, name_to_class, entry)
82
+ registered_type = @type_registry[entry]
83
+
84
+ if !registered_type.nil?
85
+ registered_type
86
+ elsif entry.is_a?(Dry::Types::Sum::Constrained)
87
+ if entry.left.type.primitive == NilClass
88
+ # it's a nullable field
89
+ _build_schema_value(ret, name_to_class, entry.right).merge(nullable: true)
90
+ else
91
+ {
92
+ anyOf: _flatten_any_of(
93
+ [
94
+ _build_schema_value(ret, name_to_class, entry.left),
95
+ _build_schema_value(ret, name_to_class, entry.right)
96
+ ]
97
+ )
98
+ }
99
+ end
100
+ elsif entry.is_a?(Dry::Types::Constrained)
101
+ # TODO: dig deeper into the actual behavior of Constrained (dry-logic)
102
+ # This is probably a can of worms. More:
103
+ # http://dry-rb.org/gems/dry-types/constraints/
104
+
105
+ _build_schema_value(ret, name_to_class, entry.type)
106
+ elsif entry.is_a?(Dry::Types::Default)
107
+ # this just unwraps the default value
108
+ _build_schema_value(ret, name_to_class, entry.type)
109
+ elsif entry.is_a?(Dry::Types::Definition)
110
+ primitive = entry.primitive
111
+
112
+ if primitive.ancestors.include?(Dry::Struct)
113
+ # TODO: make sure I'm understanding this correctly
114
+ # It feels weird to have to oneOf a $ref, but I can't figure out a
115
+ # syntax that doesn't require it.
116
+ _build_struct(ret, name_to_class, primitive)
117
+
118
+ {
119
+ oneOf: [
120
+ _struct_ref(primitive)
121
+ ]
122
+ }
123
+ elsif primitive.ancestors.include?(Hash)
124
+ _build_object_from_schema(ret, name_to_class, entry.member_types)
125
+ elsif primitive.ancestors.include?(Array)
126
+ {
127
+ type: "array",
128
+ items: _build_schema_value(ret, name_to_class, entry.member)
129
+ }
130
+ else
131
+ raise "unrecognized primitive definition '#{primitive.name}'; probably needs a literal."
132
+ end
133
+ else
134
+ raise "Unrecognized schema class: #{entry.class.name}: #{entry.inspect}"
135
+ end
136
+ end
137
+
138
+ def _flatten_any_of(typehash_array)
139
+ # This is hacky, but it removes the incidence of something like this:
140
+ #
141
+ # :exsub => {
142
+ # :anyOf => [
143
+ # [0] {
144
+ # :oneOf => [
145
+ # [0] {
146
+ # :$ref => "#/components/schemas/ExclusiveSubA"
147
+ # }
148
+ # ]
149
+ # },
150
+ # [1] {
151
+ # :oneOf => [
152
+ # [0] {
153
+ # :$ref => "#/components/schemas/ExclusiveSubB"
154
+ # }
155
+ # ]
156
+ # }
157
+ # ]
158
+ # }
159
+
160
+ typehash_array.map do |typehash|
161
+ if typehash.length == 1 && typehash.first.first == :oneOf
162
+ typehash.first.last
163
+ else
164
+ typehash
165
+ end
166
+ end.flatten
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Modern
4
+ module DocGenerator
5
+ class OpenAPI3
6
+ module Schemas
7
+ def _security_schemes(descriptor)
8
+ descriptor.securities_by_name.map do |name, security|
9
+ [name, security.to_openapi3.compact]
10
+ end.to_h
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+
6
+ require 'modern/descriptor'
7
+ require 'modern/descriptor/converters'
8
+
9
+ require 'modern/version'
10
+
11
+ Dir["#{__dir__}/open_api3/*.rb"].each { |f| require_relative f }
12
+
13
+ module Modern
14
+ module DocGenerator
15
+ class OpenAPI3
16
+ include Schemas
17
+ include Paths
18
+
19
+ # TODO: this is pretty inflexible. Eventually, consumers may want to
20
+ # subclass Route, Content, etc., for their own use cases, and that
21
+ # would make using an external/visiting doc generator impractical.
22
+ # It's simple enough, though, so let's roll with it for now.
23
+
24
+ OPENAPI_VERSION = Modern::OPENAPI_VERSION
25
+
26
+ def initialize
27
+ @type_registry = {}
28
+ _register_default_types!
29
+ end
30
+
31
+ def json(descriptor)
32
+ JSON.pretty_generate(hash(descriptor))
33
+ end
34
+
35
+ def yaml(descriptor)
36
+ # TODO: this hack exists just to de-symbolize the output without spending
37
+ # a bunch of time on it, because we don't have ActiveSupport and
38
+ # #deep_stringify_keys. It happens once at startup, it's not a big
39
+ # deal.
40
+ YAML.dump(JSON.parse(json(descriptor)))
41
+ end
42
+
43
+ def both(descriptor)
44
+ j = JSON.pretty_generate(hash(descriptor))
45
+
46
+ {
47
+ json: j,
48
+ yaml: YAML.dump(JSON.parse(j))
49
+ }
50
+ end
51
+
52
+ def hash(descriptor)
53
+ ret = {
54
+ openapi: OPENAPI_VERSION,
55
+
56
+ info: _info(descriptor.info),
57
+ servers: descriptor.servers.empty? ? nil : descriptor.servers.map(&:to_h),
58
+ paths: _paths(descriptor),
59
+ components: _components(descriptor)
60
+ }.compact
61
+
62
+ ret
63
+ end
64
+
65
+ def decorate_with_openapi_routes(configuration, descriptor)
66
+ docs = both(descriptor)
67
+
68
+ openapi3_json = docs[:json].freeze
69
+ openapi3_yaml = docs[:yaml].freeze
70
+
71
+ serve_json = Modern::Descriptor::Route.new(
72
+ id: "serveOpenApi3Json",
73
+ http_method: :GET,
74
+ path: configuration.open_api_json_path,
75
+ summary: "Serves the OpenAPI3 application spec in JSON form.",
76
+ responses: [
77
+ Modern::Descriptor::Response.new(
78
+ http_code: :default,
79
+ content: [Modern::Descriptor::Content.new(media_type: "application/json")]
80
+ )
81
+ ],
82
+ output_converters: [
83
+ Modern::Descriptor::Converters::Output::JSONBypass
84
+ ],
85
+ action:
86
+ proc do
87
+ response.bypass!
88
+ response.write(openapi3_json)
89
+ end
90
+ )
91
+
92
+ serve_yaml = Modern::Descriptor::Route.new(
93
+ id: "serveOpenApi3Yaml",
94
+ http_method: :GET,
95
+ path: configuration.open_api_yaml_path,
96
+ summary: "Serves the OpenAPI3 application spec in YAML form.",
97
+ responses: [
98
+ Modern::Descriptor::Response.new(
99
+ http_code: :default,
100
+ content: [Modern::Descriptor::Content.new(media_type: "application/yaml")]
101
+ )
102
+ ],
103
+ output_converters: [
104
+ Modern::Descriptor::Converters::Output::YAMLBypass
105
+ ],
106
+ action:
107
+ proc do
108
+ response.bypass!
109
+ response.write(openapi3_yaml)
110
+ end
111
+ )
112
+
113
+ [serve_json, serve_yaml, descriptor.routes].flatten
114
+ end
115
+
116
+ def _info(obj)
117
+ obj.to_h.compact
118
+ end
119
+
120
+ def _components(descriptor)
121
+ # TODO: figure out if we can omit the empty hashes below. We're probably never
122
+ # going to emit a "minimal" doc; I can't see ever trying to dedupe request
123
+ # bodies or whatever. (We do security schemes because it's easier and we
124
+ # do schemas to provide a reason for using dry-struct over a bunch of
125
+ # hashes.)
126
+ ret = {
127
+ schemas: _struct_schemas(descriptor),
128
+ responses: {},
129
+ parameters: {},
130
+ examples: {},
131
+ requestBodies: {},
132
+ securitySchemes: _security_schemes(descriptor),
133
+ links: {},
134
+ callbacks: {}
135
+ }
136
+
137
+ ret
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'modern/descriptor/info'
4
+
5
+ require 'docile'
6
+
7
+ module Modern
8
+ module DSL
9
+ class Info
10
+ attr_reader :value
11
+
12
+ def initialize(title, version)
13
+ @value = Modern::Descriptor::Info.new(title: title, version: version)
14
+ end
15
+
16
+ def description(v)
17
+ @value = @value.copy(description: v)
18
+ end
19
+
20
+ def terms_of_service(v)
21
+ @value = @value.copy(terms_of_service: v)
22
+ end
23
+
24
+ def contact(name: nil, url: nil, email: nil)
25
+ @value = @value.copy(contact: { name: name, url: url, email: email }.compact)
26
+ end
27
+
28
+ def license(name, url: nil)
29
+ @value = @value.copy(license: { name: name, url: url }.compact)
30
+ end
31
+
32
+ def self.build(title, version, &block)
33
+ i = Info.new(title, version)
34
+ i.instance_exec(&block)
35
+ i.value
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'modern/descriptor/response'
4
+ require 'modern/descriptor/content'
5
+
6
+ require 'docile'
7
+
8
+ module Modern
9
+ module DSL
10
+ class ResponseBuilder
11
+ attr_reader :value
12
+
13
+ def initialize(http_code_or_response)
14
+ @value =
15
+ if http_code_or_response.is_a?(Modern::Descriptor::Response)
16
+ http_code_or_response
17
+ else
18
+ Modern::Descriptor::Response.new(http_code: http_code_or_response)
19
+ end
20
+ end
21
+
22
+ def description(s)
23
+ @value = @value.copy(description: s)
24
+ end
25
+
26
+ def content(media_type, type = nil)
27
+ raise "Duplicate content type: #{media_type}" \
28
+ if @value.content.any? { |c| c.media_type.casecmp(media_type).zero? }
29
+
30
+ new_content = Modern::Descriptor::Content.new(media_type: media_type, type: type)
31
+ @value = @value.copy(content: @value.content + [new_content])
32
+ end
33
+
34
+ def self.evaluate(http_code_or_response, &block)
35
+ builder = ResponseBuilder.new(http_code_or_response)
36
+ builder.instance_exec(&block)
37
+ builder.value
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'modern/dsl/info'
4
+ require 'modern/dsl/scope'
5
+
6
+ module Modern
7
+ module DSL
8
+ class Root < Scope
9
+ attr_reader :descriptor
10
+
11
+ def initialize(descriptor)
12
+ super(descriptor, nil)
13
+ end
14
+
15
+ def info(&block)
16
+ @descriptor = @descriptor.copy(
17
+ info: Info.build(descriptor.info.title, descriptor.info.version, &block).to_h
18
+ )
19
+ end
20
+
21
+ def server(url, description: nil)
22
+ raise "url is required for a server declaration." if url.nil?
23
+
24
+ @descriptor = @descriptor.copy(
25
+ servers: @descriptor.servers + [Modern::Descriptor::Server.new(url: url, description: description)]
26
+ )
27
+ end
28
+
29
+ def self.build(title, version, &block)
30
+ d = Modern::Descriptor::Core.new(info: { title: title, version: version })
31
+
32
+ r = Root.new(d)
33
+ r.instance_exec(&block)
34
+ r.descriptor
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'modern/descriptor/route'
4
+
5
+ require 'modern/dsl/response_builder'
6
+
7
+ require 'docile'
8
+
9
+ module Modern
10
+ module DSL
11
+ class RouteBuilder
12
+ attr_reader :value
13
+
14
+ def initialize(id, http_method, path, settings)
15
+ new_path_segments = path&.split("/") || []
16
+ @value = Modern::Descriptor::Route.new(
17
+ id: id.to_s,
18
+ path: "/" + ([settings.path_segments] + new_path_segments).flatten.compact.join("/"),
19
+ http_method: http_method.upcase,
20
+ summary: nil,
21
+ description: nil,
22
+ deprecated: settings.deprecated,
23
+ tags: settings.tags,
24
+ parameters: settings.parameters,
25
+ request_body: nil,
26
+ responses: [settings.default_response],
27
+ input_converters: settings.input_converters,
28
+ output_converters: settings.output_converters,
29
+ security: settings.security,
30
+ helpers: settings.helpers,
31
+ action: proc {}
32
+ )
33
+ end
34
+
35
+ def summary(s)
36
+ @value = @value.copy(summary: s)
37
+ end
38
+
39
+ def description(s)
40
+ @value = @value.copy(description: s)
41
+ end
42
+
43
+ def deprecate!
44
+ @value = @value.copy(deprecated: true)
45
+ end
46
+
47
+ def tag(s)
48
+ @value = @value.copy(tags: @value.tags + [s])
49
+ end
50
+
51
+ def parameter(name, parameter_type, opts)
52
+ param = Modern::Descriptor::Parameters.from_inputs(name, parameter_type, opts)
53
+ raise "Duplicate parameter '#{name}'.'" if @value.parameters.any? { |p| p.name.casecmp(param.name).zero? }
54
+
55
+ @value = @value.copy(parameters: @value.parameters + [param])
56
+ end
57
+
58
+ def request_body(type, opts = { required: true })
59
+ opts = { required: true }.merge(opts).merge(type: type)
60
+ @value = @value.copy(request_body: Modern::Descriptor::RequestBody.new(opts))
61
+ end
62
+
63
+ def response(http_code, &block)
64
+ existing_response = @value.responses.find { |r| r.http_code == http_code }
65
+
66
+ resp = ResponseBuilder.evaluate(existing_response || http_code, &block)
67
+ @value = @value.copy(responses: @value.responses + [resp])
68
+ end
69
+
70
+ def input_converter(media_type_or_converter, &block)
71
+ if media_type_or_converter.is_a?(Modern::Descriptor::Converters::Input::Base)
72
+ @value = @value.copy(input_converters: @value.input_converters + [media_type_or_converter])
73
+ elsif media_type_or_converter.is_a?(String) && !block.nil?
74
+ input_converter(
75
+ Modern::Descriptor::Converters::Input::Base.new(
76
+ media_type: media_type_or_converter, converter: block
77
+ )
78
+ )
79
+ else
80
+ raise "must pass a String and block or a Modern::Descriptor::Converters::Input::Base."
81
+ end
82
+ end
83
+
84
+ def clear_input_converters!
85
+ @value = @value.copy(input_converters: [])
86
+ end
87
+
88
+ def output_converter(media_type_or_converter, &block)
89
+ if media_type_or_converter.is_a?(Modern::Descriptor::Converters::Output::Base)
90
+ @value = @value.copy(output_converters: @value.output_converters + [media_type_or_converter])
91
+ elsif media_type_or_converter.is_a?(String) && !block.nil?
92
+ output_converter(
93
+ Modern::Descriptor::Converters::Output::Base.new(
94
+ media_type: media_type_or_converter, converter: block
95
+ )
96
+ )
97
+ else
98
+ raise "must pass a String and block or a Modern::Descriptor::Converters::Output::Base."
99
+ end
100
+ end
101
+
102
+ def clear_output_converters!
103
+ @value = @value.copy(output_converters: [])
104
+ end
105
+
106
+ def clear_security!
107
+ @value = @value.copy(security: [])
108
+ end
109
+
110
+ def security(sec)
111
+ @value = @value.copy(security: @value.security + [sec])
112
+ end
113
+
114
+ def helper(h)
115
+ @value = @value.copy(helpers: @value.helpers + [h])
116
+ end
117
+
118
+ def action(&block)
119
+ @value = @value.copy(action: block)
120
+ end
121
+
122
+ def self.evaluate(id, http_method, path, settings, &block)
123
+ builder = RouteBuilder.new(id, http_method, path, settings)
124
+ builder.instance_exec(&block)
125
+
126
+ builder.value
127
+ end
128
+ end
129
+ end
130
+ end