sober_swag 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/ruby.yml +33 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/.rubocop.yml +7 -0
- data/.ruby-version +1 -0
- data/.travis.yml +7 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +92 -0
- data/LICENSE.txt +21 -0
- data/README.md +7 -0
- data/Rakefile +8 -0
- data/bin/console +38 -0
- data/bin/setup +8 -0
- data/example/.gitignore +24 -0
- data/example/.ruby-version +1 -0
- data/example/Gemfile +42 -0
- data/example/Gemfile.lock +212 -0
- data/example/README.md +24 -0
- data/example/Rakefile +6 -0
- data/example/app/controllers/application_controller.rb +2 -0
- data/example/app/controllers/concerns/.keep +0 -0
- data/example/app/controllers/people_controller.rb +74 -0
- data/example/app/jobs/application_job.rb +7 -0
- data/example/app/models/application_record.rb +3 -0
- data/example/app/models/concerns/.keep +0 -0
- data/example/app/models/person.rb +2 -0
- data/example/bin/bundle +114 -0
- data/example/bin/rails +9 -0
- data/example/bin/rake +9 -0
- data/example/bin/setup +33 -0
- data/example/bin/spring +17 -0
- data/example/config/application.rb +37 -0
- data/example/config/boot.rb +4 -0
- data/example/config/credentials.yml.enc +1 -0
- data/example/config/database.yml +25 -0
- data/example/config/environment.rb +5 -0
- data/example/config/environments/development.rb +44 -0
- data/example/config/environments/production.rb +91 -0
- data/example/config/environments/test.rb +38 -0
- data/example/config/initializers/application_controller_renderer.rb +8 -0
- data/example/config/initializers/backtrace_silencers.rb +7 -0
- data/example/config/initializers/cors.rb +16 -0
- data/example/config/initializers/filter_parameter_logging.rb +4 -0
- data/example/config/initializers/inflections.rb +16 -0
- data/example/config/initializers/mime_types.rb +4 -0
- data/example/config/initializers/wrap_parameters.rb +14 -0
- data/example/config/locales/en.yml +33 -0
- data/example/config/puma.rb +38 -0
- data/example/config/routes.rb +6 -0
- data/example/config/spring.rb +6 -0
- data/example/config.ru +5 -0
- data/example/db/migrate/20200311152021_create_people.rb +12 -0
- data/example/db/schema.rb +23 -0
- data/example/db/seeds.rb +7 -0
- data/example/lib/tasks/.keep +0 -0
- data/example/log/.keep +0 -0
- data/example/person.json +4 -0
- data/example/public/robots.txt +1 -0
- data/example/test/controllers/.keep +0 -0
- data/example/test/fixtures/.keep +0 -0
- data/example/test/fixtures/files/.keep +0 -0
- data/example/test/fixtures/people.yml +11 -0
- data/example/test/integration/.keep +0 -0
- data/example/test/models/.keep +0 -0
- data/example/test/models/person_test.rb +7 -0
- data/example/test/test_helper.rb +13 -0
- data/example/tmp/.keep +0 -0
- data/example/vendor/.keep +0 -0
- data/lib/sober_swag/blueprint/field.rb +36 -0
- data/lib/sober_swag/blueprint/field_syntax.rb +17 -0
- data/lib/sober_swag/blueprint/view.rb +44 -0
- data/lib/sober_swag/blueprint.rb +113 -0
- data/lib/sober_swag/compiler/error.rb +5 -0
- data/lib/sober_swag/compiler/path.rb +80 -0
- data/lib/sober_swag/compiler/paths.rb +54 -0
- data/lib/sober_swag/compiler/type.rb +235 -0
- data/lib/sober_swag/compiler.rb +107 -0
- data/lib/sober_swag/controller/route.rb +136 -0
- data/lib/sober_swag/controller/undefined_body_error.rb +6 -0
- data/lib/sober_swag/controller/undefined_path_error.rb +6 -0
- data/lib/sober_swag/controller/undefined_query_error.rb +6 -0
- data/lib/sober_swag/controller.rb +157 -0
- data/lib/sober_swag/nodes/array.rb +30 -0
- data/lib/sober_swag/nodes/attribute.rb +31 -0
- data/lib/sober_swag/nodes/base.rb +51 -0
- data/lib/sober_swag/nodes/binary.rb +44 -0
- data/lib/sober_swag/nodes/enum.rb +28 -0
- data/lib/sober_swag/nodes/list.rb +40 -0
- data/lib/sober_swag/nodes/nullable_primitive.rb +6 -0
- data/lib/sober_swag/nodes/object.rb +12 -0
- data/lib/sober_swag/nodes/one_of.rb +12 -0
- data/lib/sober_swag/nodes/primitive.rb +29 -0
- data/lib/sober_swag/nodes/sum.rb +6 -0
- data/lib/sober_swag/nodes.rb +20 -0
- data/lib/sober_swag/parser.rb +73 -0
- data/lib/sober_swag/path/integer.rb +21 -0
- data/lib/sober_swag/path/lit.rb +41 -0
- data/lib/sober_swag/path/literal.rb +29 -0
- data/lib/sober_swag/path/param.rb +33 -0
- data/lib/sober_swag/path.rb +8 -0
- data/lib/sober_swag/serializer/array.rb +21 -0
- data/lib/sober_swag/serializer/base.rb +38 -0
- data/lib/sober_swag/serializer/conditional.rb +49 -0
- data/lib/sober_swag/serializer/field_list.rb +44 -0
- data/lib/sober_swag/serializer/mapped.rb +29 -0
- data/lib/sober_swag/serializer/optional.rb +29 -0
- data/lib/sober_swag/serializer/primitive.rb +15 -0
- data/lib/sober_swag/serializer.rb +23 -0
- data/lib/sober_swag/types.rb +5 -0
- data/lib/sober_swag/version.rb +5 -0
- data/lib/sober_swag.rb +29 -0
- data/sober_swag.gemspec +40 -0
- metadata +269 -0
@@ -0,0 +1,107 @@
|
|
1
|
+
module SoberSwag
|
2
|
+
##
|
3
|
+
# Compiler for an entire API.
|
4
|
+
#
|
5
|
+
# This compiler has a *lot* of state as we need to get
|
6
|
+
class Compiler
|
7
|
+
autoload(:Type, 'sober_swag/compiler/type')
|
8
|
+
autoload(:Error, 'sober_swag/compiler/error')
|
9
|
+
autoload(:Path, 'sober_swag/compiler/path')
|
10
|
+
autoload(:Paths, 'sober_swag/compiler/paths')
|
11
|
+
|
12
|
+
def initialize
|
13
|
+
@types = Set.new
|
14
|
+
@paths = Paths.new
|
15
|
+
end
|
16
|
+
|
17
|
+
##
|
18
|
+
# Convert a compiler to the overall type definition.
|
19
|
+
def to_swagger
|
20
|
+
{
|
21
|
+
paths: path_schemas,
|
22
|
+
components: {
|
23
|
+
schemas: object_schemas
|
24
|
+
}
|
25
|
+
}
|
26
|
+
end
|
27
|
+
|
28
|
+
##
|
29
|
+
# Add a path to be compiled.
|
30
|
+
# @param route [SoberSwag::Controller::Route] the route to add.
|
31
|
+
def add_route(route)
|
32
|
+
tap { @paths.add_route(route) }
|
33
|
+
end
|
34
|
+
|
35
|
+
def object_schemas
|
36
|
+
@types.map { |v| [v.ref_name, v.object_schema] }.to_h
|
37
|
+
end
|
38
|
+
|
39
|
+
##
|
40
|
+
# The path section of the swagger schema.
|
41
|
+
def path_schemas
|
42
|
+
@paths.paths_list(self)
|
43
|
+
end
|
44
|
+
|
45
|
+
##
|
46
|
+
# Compile a type to a new, path-params list.
|
47
|
+
# This will add all subtypes to the found types list.
|
48
|
+
def path_params_for(type)
|
49
|
+
with_types_discovered(type).path_schema
|
50
|
+
end
|
51
|
+
|
52
|
+
##
|
53
|
+
# Get the query params list for a type.
|
54
|
+
# All found types will be added to the reference dictionary.
|
55
|
+
def query_params_for(type)
|
56
|
+
with_types_discovered(type).query_schema
|
57
|
+
end
|
58
|
+
|
59
|
+
##
|
60
|
+
# Get the request body definition for a type.
|
61
|
+
# This will always be a ref.
|
62
|
+
def body_for(type)
|
63
|
+
add_type(type)
|
64
|
+
Type.new(type).schema_stub
|
65
|
+
end
|
66
|
+
|
67
|
+
##
|
68
|
+
# Get the definition of a response type
|
69
|
+
def response_for(type)
|
70
|
+
body_for(type)
|
71
|
+
end
|
72
|
+
|
73
|
+
##
|
74
|
+
# Get the existing schema for a given type
|
75
|
+
def schema_for(type)
|
76
|
+
@types.find { |type_comp| type_comp.type == type }&.object_schema
|
77
|
+
end
|
78
|
+
|
79
|
+
##
|
80
|
+
# Add a type in the types reference dictionary, essentially
|
81
|
+
def add_type(type)
|
82
|
+
# use tap here to avoid an explicit self at the end of this
|
83
|
+
# which makes this method chainable
|
84
|
+
tap do
|
85
|
+
type_compiler = Type.new(type)
|
86
|
+
|
87
|
+
##
|
88
|
+
# Do nothing if we already have a type
|
89
|
+
return self if @types.include?(type_compiler)
|
90
|
+
|
91
|
+
@types.add(type_compiler) if type_compiler.standalone?
|
92
|
+
|
93
|
+
type_compiler.found_types.each do |ft|
|
94
|
+
add_type(ft)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
private
|
100
|
+
|
101
|
+
def with_types_discovered(type)
|
102
|
+
Type.new(type).tap do |type_compiler|
|
103
|
+
type_compiler.found_types.each { |ft| add_type(ft) }
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,136 @@
|
|
1
|
+
require 'sober_swag/blueprint'
|
2
|
+
|
3
|
+
module SoberSwag
|
4
|
+
module Controller
|
5
|
+
class Route
|
6
|
+
|
7
|
+
def initialize(method, action_name, path)
|
8
|
+
@method = method
|
9
|
+
@path = path
|
10
|
+
@action_name = action_name
|
11
|
+
@response_serializers = {}
|
12
|
+
@response_descriptions = {}
|
13
|
+
end
|
14
|
+
|
15
|
+
attr_reader :response_serializers
|
16
|
+
attr_reader :response_descriptions
|
17
|
+
attr_reader :controller
|
18
|
+
attr_reader :method
|
19
|
+
attr_reader :path
|
20
|
+
attr_reader :action_name
|
21
|
+
##
|
22
|
+
# What to parse the request body in to.
|
23
|
+
attr_reader :request_body_class
|
24
|
+
##
|
25
|
+
# What to parse the request query_params in to
|
26
|
+
attr_reader :query_params_class
|
27
|
+
|
28
|
+
##
|
29
|
+
# What to parse the path params into
|
30
|
+
attr_reader :path_params_class
|
31
|
+
|
32
|
+
##
|
33
|
+
# Define the request body, using SoberSwag's type-definition scheme.
|
34
|
+
# The block passed will be used to define the body of a new sublcass of `base` (defaulted to {Dry::Struct}.)
|
35
|
+
# If you want, you can also define utility methods in here
|
36
|
+
def request_body(base = Dry::Struct, &block)
|
37
|
+
@request_body_class = make_struct!(base, &block)
|
38
|
+
action_module.const_set('ResponseBody', @request_body_class)
|
39
|
+
end
|
40
|
+
|
41
|
+
##
|
42
|
+
# Does this route have a body defined?
|
43
|
+
def request_body?
|
44
|
+
!request_body_class.nil?
|
45
|
+
end
|
46
|
+
|
47
|
+
##
|
48
|
+
# Define the shape of the query_params parameters, using SoberSwag's type-definition scheme.
|
49
|
+
# The block passed is the body of the newly-defined type.
|
50
|
+
# You can also include a base type.
|
51
|
+
def query_params(base = Dry::Struct, &block)
|
52
|
+
@query_params_class = make_struct!(base, &block)
|
53
|
+
action_module.const_set('QueryParams', @query_params_class)
|
54
|
+
end
|
55
|
+
|
56
|
+
##
|
57
|
+
# Does this route have query params defined?
|
58
|
+
def query_params?
|
59
|
+
!query_params_class.nil?
|
60
|
+
end
|
61
|
+
|
62
|
+
##
|
63
|
+
# Define the shape of the *path* parameters, using SoberSwag's type-definition scheme.
|
64
|
+
# The block passed will be the body of a new subclass of `base` (defaulted to {Dry::Struct}).
|
65
|
+
# Names of this should match the names in the path template originally passed to {SoberSwag::Controller::Route.new}
|
66
|
+
def path_params(base = Dry::Struct, &block)
|
67
|
+
@path_params_class = make_struct!(base, &block)
|
68
|
+
action_module.const_set('PathParams', @path_params_class)
|
69
|
+
end
|
70
|
+
|
71
|
+
##
|
72
|
+
# Does this route have path params defined?
|
73
|
+
def path_params?
|
74
|
+
!path_params_class.nil?
|
75
|
+
end
|
76
|
+
|
77
|
+
##
|
78
|
+
# Define the body of the action method in the controller.
|
79
|
+
def action(&body)
|
80
|
+
return @action if body.nil?
|
81
|
+
|
82
|
+
@action ||= body
|
83
|
+
end
|
84
|
+
|
85
|
+
def description(desc = nil)
|
86
|
+
return @description if desc.nil?
|
87
|
+
|
88
|
+
@description = desc
|
89
|
+
end
|
90
|
+
|
91
|
+
def summary(sum = nil)
|
92
|
+
return @summary if sum.nil?
|
93
|
+
|
94
|
+
@summary = sum
|
95
|
+
end
|
96
|
+
|
97
|
+
##
|
98
|
+
# The container module for all the constants this will eventually define.
|
99
|
+
# Each class generated by this Route will be defined within this module.
|
100
|
+
def action_module
|
101
|
+
@action_module ||= Module.new
|
102
|
+
end
|
103
|
+
|
104
|
+
##
|
105
|
+
# Define a serializer for a response with the given status code.
|
106
|
+
# You may either give a serializer you defined elsewhere, or define one inline as if passed to
|
107
|
+
# {SoberSwag::Blueprint.define}
|
108
|
+
def response(status_code, description, serializer = nil, &block)
|
109
|
+
status_key = Rack::Utils.status_code(status_code)
|
110
|
+
|
111
|
+
raise ArgumentError, 'Response defiend!' if @response_serializers.key?(status_key)
|
112
|
+
|
113
|
+
serializer ||= SoberSwag::Blueprint.define(&block)
|
114
|
+
response_module.const_set(status_code.to_s.classify, serializer)
|
115
|
+
@response_serializers[status_key] = serializer
|
116
|
+
@response_descriptions[status_key] = description
|
117
|
+
end
|
118
|
+
|
119
|
+
##
|
120
|
+
# What you should call the module of this action in your controller
|
121
|
+
def action_module_name
|
122
|
+
action_name.to_s.classify
|
123
|
+
end
|
124
|
+
|
125
|
+
private
|
126
|
+
|
127
|
+
def response_module
|
128
|
+
@response_module ||= Module.new.tap { |m| action_module.const_set(:Response, m) }
|
129
|
+
end
|
130
|
+
|
131
|
+
def make_struct!(base, &block)
|
132
|
+
Class.new(base, &block).tap { |e| e.transform_keys(&:to_sym) if base == Dry::Struct }
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
@@ -0,0 +1,157 @@
|
|
1
|
+
require 'active_support/concern'
|
2
|
+
|
3
|
+
module SoberSwag
|
4
|
+
##
|
5
|
+
# Controller concern
|
6
|
+
module Controller
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
autoload :UndefinedBodyError, 'sober_swag/controller/undefined_body_error'
|
10
|
+
autoload :UndefinedPathError, 'sober_swag/controller/undefined_path_error'
|
11
|
+
autoload :UndefinedQueryError, 'sober_swag/controller/undefined_query_error'
|
12
|
+
|
13
|
+
module Types
|
14
|
+
include ::Dry::Types()
|
15
|
+
end
|
16
|
+
|
17
|
+
class_methods do
|
18
|
+
##
|
19
|
+
# Define a new action with the given HTTP method, action name, and path.
|
20
|
+
# This will eventaully delegate to making an actual method on your controller,
|
21
|
+
# so you can use controllers as you wish with no harm.
|
22
|
+
#
|
23
|
+
# This method takes a block, evaluated in the context of a {SoberSwag::Controller::Route}.
|
24
|
+
# Used like:
|
25
|
+
# define(:get, :show, '/posts/{id}') do
|
26
|
+
# path_params do
|
27
|
+
# attribute :id, Types::Integer
|
28
|
+
# end
|
29
|
+
# action do
|
30
|
+
# @post = Post.find(parsed_path.id)
|
31
|
+
# render json: @post
|
32
|
+
# end
|
33
|
+
# end
|
34
|
+
#
|
35
|
+
# This will define an "action module" on this class to contain the generated types.
|
36
|
+
# In the above example, the following constants will be deifned on the controller:
|
37
|
+
# PostsController::Show # the container module for everything in this action
|
38
|
+
# PostsController::Show::PathParams # the dry-struct type for the path attribute.
|
39
|
+
# So, in the same controller, you can refer to Show::PathParams to get the type created by the 'path_params' block above.
|
40
|
+
def define(method, action, path, &block)
|
41
|
+
r = Route.new(method, action, path)
|
42
|
+
r.instance_eval(&block)
|
43
|
+
const_set(r.action_module_name, r.action_module)
|
44
|
+
defined_routes << r
|
45
|
+
define_method(action, r.action) if r.action
|
46
|
+
end
|
47
|
+
|
48
|
+
##
|
49
|
+
# All the routes that this controller knows about.
|
50
|
+
def defined_routes
|
51
|
+
@defined_routes ||= []
|
52
|
+
end
|
53
|
+
|
54
|
+
##
|
55
|
+
# Find a route with the given name.
|
56
|
+
def find_route(name)
|
57
|
+
defined_routes.find { |r| r.action_name.to_s == name.to_s }
|
58
|
+
end
|
59
|
+
|
60
|
+
##
|
61
|
+
# A swagger definition for *this controller only*.
|
62
|
+
def swagger_info
|
63
|
+
@swagger_info ||=
|
64
|
+
begin
|
65
|
+
res = defined_routes.reduce(SoberSwag::Compiler.new) { |c, r| c.add_route(r) }
|
66
|
+
{
|
67
|
+
openapi: '3.0.0',
|
68
|
+
info: {
|
69
|
+
version: '1',
|
70
|
+
title: self.name
|
71
|
+
}
|
72
|
+
}.merge(res.to_swagger)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
##
|
78
|
+
# Action to get the singular swagger for this entire API.
|
79
|
+
def swagger
|
80
|
+
render json: self.class.swagger_info
|
81
|
+
end
|
82
|
+
|
83
|
+
##
|
84
|
+
# Get the path parameters, parsed into the type you defined with {SoberSwag::Controller.define}
|
85
|
+
# @raise [UndefinedPathError] if there's no path params defined for this route
|
86
|
+
# @raise [Dry::Struct::Error] if we cannot convert the path params to the defined type.
|
87
|
+
def parsed_path
|
88
|
+
@parsed_path ||=
|
89
|
+
begin
|
90
|
+
r = current_action_def
|
91
|
+
raise UndefinedPathError unless r&.path_params_class
|
92
|
+
r.path_params_class.new(request.path_parameters)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
##
|
97
|
+
# Get the request body, parsed into the type you defined with {SoberSwag::Controller.define}.
|
98
|
+
# @raise [UndefinedBodyError] if there's no request body defined for this route
|
99
|
+
# @raise [Dry::Struct::Error] if we cannot convert the path params to the defined type.
|
100
|
+
def parsed_body
|
101
|
+
@parsed_body ||=
|
102
|
+
begin
|
103
|
+
r = current_action_def
|
104
|
+
raise UndefinedBodyError unless r&.request_body_class
|
105
|
+
r.request_body_class.new(body_params)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
##
|
110
|
+
# Get the query params, parsed into the type you defined with {SoberSwag::Controller.define}
|
111
|
+
# @raise [UndefinedQueryError] if there's no query params defined for this route
|
112
|
+
# @raise [Dry::Struct::Error] if we cannot convert the path params to the defined type.
|
113
|
+
def parsed_query
|
114
|
+
@parsed_query ||=
|
115
|
+
begin
|
116
|
+
r = current_action_def
|
117
|
+
raise UndefinedQueryError unless r&.query_params_class
|
118
|
+
r.query_params_class.new(request.query_parameters)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
##
|
123
|
+
# Respond with the serialized type that you defined for this route.
|
124
|
+
# @todo figure out how to specify views and other options for the serializer here
|
125
|
+
# @param status [Symbol] the HTTP status symbol to use for the status code
|
126
|
+
# @param entity the thing to serialize
|
127
|
+
def respond!(status, entity)
|
128
|
+
r = current_action_def
|
129
|
+
serializer = r.response_serializers[Rack::Utils.status_code(status)]
|
130
|
+
serializer ||= serializer.new if serializer.respond_to?(:new)
|
131
|
+
render json: serializer.serialize(entity)
|
132
|
+
end
|
133
|
+
|
134
|
+
##
|
135
|
+
# Obtain a parameters hash of *only* those parameters which come in the hash.
|
136
|
+
# These will be *unsafe* in the sense that they will all be allowed.
|
137
|
+
# This kinda violates the "be liberal in what you accept" principle,
|
138
|
+
# but it keeps the docs honest: parameters sent in the body *must* be
|
139
|
+
# in the body.
|
140
|
+
def body_params
|
141
|
+
bparams = params.reject do |k, _|
|
142
|
+
request.query_parameters.key?(k) || request.path_parameters.key?(k)
|
143
|
+
end
|
144
|
+
bparams.permit(bparams.keys)
|
145
|
+
end
|
146
|
+
|
147
|
+
##
|
148
|
+
# Get the action-definition for the current action.
|
149
|
+
# Under the hood, delegates to the `:action` key of rails params.
|
150
|
+
def current_action_def
|
151
|
+
self.class.find_route(params[:action])
|
152
|
+
end
|
153
|
+
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
require 'sober_swag/controller/route'
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module SoberSwag
|
2
|
+
module Nodes
|
3
|
+
##
|
4
|
+
# Base class for nodes that contain arrays of other nodes.
|
5
|
+
# This is very different from an attribute representing a node which *is* an array of some element type!!
|
6
|
+
class Array < Base
|
7
|
+
def initialize(elements)
|
8
|
+
@elements = elements
|
9
|
+
end
|
10
|
+
|
11
|
+
attr_reader :elements
|
12
|
+
|
13
|
+
def map(&block)
|
14
|
+
self.class.new(elements.map { |elem| elem.map(&block) })
|
15
|
+
end
|
16
|
+
|
17
|
+
def cata(&block)
|
18
|
+
block.call(self.class.new(elements.map { |elem| elem.cata(&block) }))
|
19
|
+
end
|
20
|
+
|
21
|
+
def deconstruct
|
22
|
+
@elements
|
23
|
+
end
|
24
|
+
|
25
|
+
def deconstruct_keys(keys)
|
26
|
+
{ elements: @elements }
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module SoberSwag
|
2
|
+
module Nodes
|
3
|
+
##
|
4
|
+
# One attribute of an object.
|
5
|
+
class Attribute
|
6
|
+
def initialize(key, required, value)
|
7
|
+
@key = key
|
8
|
+
@required = required
|
9
|
+
@value = value
|
10
|
+
end
|
11
|
+
|
12
|
+
def deconstruct
|
13
|
+
[key, required, value]
|
14
|
+
end
|
15
|
+
|
16
|
+
def deconstruct_keys
|
17
|
+
{ key: key, required: required, value: value }
|
18
|
+
end
|
19
|
+
|
20
|
+
attr_reader :key, :required, :value
|
21
|
+
|
22
|
+
def map(&block)
|
23
|
+
self.class.new(key, required, value.map(&block))
|
24
|
+
end
|
25
|
+
|
26
|
+
def cata(&block)
|
27
|
+
block.call(self.class.new(key, required, value.cata(&block)))
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module SoberSwag
|
2
|
+
module Nodes
|
3
|
+
##
|
4
|
+
# Base Node that all other nodes inherit from.
|
5
|
+
# All nodes should define the following:
|
6
|
+
#
|
7
|
+
# - #deconstruct, which returns an array of *everything needed to idenitfy the node.*
|
8
|
+
# We base comparisons on the result of deconstruction.
|
9
|
+
# - #deconstruct_keys, which returns a hash of *everything needed to identify the node*.
|
10
|
+
# We use this later.
|
11
|
+
class Base
|
12
|
+
|
13
|
+
include Comparable
|
14
|
+
|
15
|
+
##
|
16
|
+
# Value-level comparison.
|
17
|
+
def <=>(other)
|
18
|
+
return other.class.name <=> self.class.name unless other.class == self.class
|
19
|
+
|
20
|
+
deconstruct <=> other.deconstruct
|
21
|
+
end
|
22
|
+
|
23
|
+
def eql?(other)
|
24
|
+
deconstruct == other.deconstruct
|
25
|
+
end
|
26
|
+
|
27
|
+
def hash
|
28
|
+
deconstruct.hash
|
29
|
+
end
|
30
|
+
|
31
|
+
##
|
32
|
+
# Perform a catamorphism, or, a deep-first recursion.
|
33
|
+
#
|
34
|
+
# The basic way this works is deceptively simple: When you use 'cata' on a node,
|
35
|
+
# it will call the block you gave it with the *deepest* nodes in the tree first.
|
36
|
+
# It will then use the result of that block to reconstruct their *parent* node, and then
|
37
|
+
# *call cata again* on the parent, and so on until we reach the top.
|
38
|
+
#
|
39
|
+
# When working with these definition nodes, we very often want to transform something recursively.
|
40
|
+
# This method allows us to do so by focusing on a single level at a time, keeping the actual recursion *abstract*.
|
41
|
+
def cata(&block)
|
42
|
+
raise ArgumentError, 'Base is abstract'
|
43
|
+
end
|
44
|
+
|
45
|
+
def map(&block)
|
46
|
+
raise ArgumentError, 'Base is abstract'
|
47
|
+
end
|
48
|
+
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
module SoberSwag
|
2
|
+
module Nodes
|
3
|
+
##
|
4
|
+
# A
|
5
|
+
#
|
6
|
+
# It's cool I promise.
|
7
|
+
class Binary < Base
|
8
|
+
def initialize(lhs, rhs)
|
9
|
+
@lhs = lhs
|
10
|
+
@rhs = rhs
|
11
|
+
end
|
12
|
+
|
13
|
+
attr_reader :lhs, :rhs
|
14
|
+
##
|
15
|
+
# Map the root values of the node.
|
16
|
+
# This just calls map on the lhs and the rhs
|
17
|
+
def map(&block)
|
18
|
+
self.class.new(
|
19
|
+
lhs.map(&block),
|
20
|
+
rhs.map(&block)
|
21
|
+
)
|
22
|
+
end
|
23
|
+
|
24
|
+
def deconstruct
|
25
|
+
[lhs, rhs]
|
26
|
+
end
|
27
|
+
|
28
|
+
def deconstruct_keys(keys)
|
29
|
+
{ lhs: lhs, rhs: rhs }
|
30
|
+
end
|
31
|
+
|
32
|
+
##
|
33
|
+
# Perform a catamorphism on this node.
|
34
|
+
def cata(&block)
|
35
|
+
block.call(
|
36
|
+
self.class.new(
|
37
|
+
lhs.cata(&block),
|
38
|
+
rhs.cata(&block)
|
39
|
+
)
|
40
|
+
)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module SoberSwag
|
2
|
+
module Nodes
|
3
|
+
class Enum < Base
|
4
|
+
|
5
|
+
def initialize(values)
|
6
|
+
@values = values
|
7
|
+
end
|
8
|
+
|
9
|
+
attr_reader :values
|
10
|
+
|
11
|
+
def map(&block)
|
12
|
+
self.class.new(@values.map(&block))
|
13
|
+
end
|
14
|
+
|
15
|
+
def deconstruct
|
16
|
+
[values]
|
17
|
+
end
|
18
|
+
|
19
|
+
def deconstruct_keys(keys)
|
20
|
+
{ values: values }
|
21
|
+
end
|
22
|
+
|
23
|
+
def cata(&block)
|
24
|
+
block.call(dup)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module SoberSwag
|
2
|
+
module Nodes
|
3
|
+
##
|
4
|
+
# A List of the contained element types.
|
5
|
+
#
|
6
|
+
# Unlike {SoberSwag::Nodes::Array}, this actually models arrays.
|
7
|
+
# The other one is a node that *is* an array in terms of what it contains.
|
8
|
+
# Kinda confusing, but oh well.
|
9
|
+
class List < Base
|
10
|
+
def initialize(element)
|
11
|
+
@element = element
|
12
|
+
end
|
13
|
+
|
14
|
+
attr_reader :element
|
15
|
+
|
16
|
+
def deconstruct
|
17
|
+
[element]
|
18
|
+
end
|
19
|
+
|
20
|
+
def deconstruct_keys(_)
|
21
|
+
{ element: element }
|
22
|
+
end
|
23
|
+
|
24
|
+
def cata(&block)
|
25
|
+
block.call(
|
26
|
+
self.class.new(
|
27
|
+
element.cata(&block)
|
28
|
+
)
|
29
|
+
)
|
30
|
+
end
|
31
|
+
|
32
|
+
def map(&block)
|
33
|
+
self.class.new(
|
34
|
+
element.map(&block)
|
35
|
+
)
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|