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