radical 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rubocop.yml +4 -0
- data/.solargraph.yml +10 -0
- data/CHANGELOG.md +27 -0
- data/Dockerfile +34 -0
- data/Gemfile +5 -0
- data/LICENSE +21 -0
- data/README.md +49 -0
- data/Rakefile +11 -0
- data/lib/radical/app.rb +103 -0
- data/lib/radical/controller.rb +144 -0
- data/lib/radical/database.rb +92 -0
- data/lib/radical/env.rb +23 -0
- data/lib/radical/form.rb +47 -0
- data/lib/radical/model.rb +128 -0
- data/lib/radical/router.rb +144 -0
- data/lib/radical/table.rb +23 -0
- data/lib/radical/view.rb +58 -0
- data/lib/radical.rb +4 -0
- data/radical.gemspec +46 -0
- data/sorbet/config +3 -0
- data/sorbet/rbi/sorbet-typed/lib/minitest/all/minitest.rbi +108 -0
- data/sorbet/rbi/sorbet-typed/lib/rake/all/rake.rbi +645 -0
- metadata +235 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 537aa5f33439e08833e0aca3ff572a0e017c20cdbfaa3d267c303191cc692fd0
|
4
|
+
data.tar.gz: 95ee9c628803bf81f237d15aa6c3d92a1d0c55f4412809eb4e74a57dd510e15c
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: f30a37e86df78f5beb7a345b96031a1d83779a723629a7b1ca3c70223d7cea94acfc277bea77d32b8461382fbed2f146b3bf69a732a77224fd4d586f7877811d
|
7
|
+
data.tar.gz: a322aef87deae2b114842c4a88ac31fc02f35258701770d9a2017158616a6d07229a5960de8879efbd403b286d4c1a53ec64ba7f9b7e202570734f8b9de0c5b1
|
data/.rubocop.yml
ADDED
data/.solargraph.yml
ADDED
data/CHANGELOG.md
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
# Changelog
|
2
|
+
All notable changes to this project will be documented in this file
|
3
|
+
|
4
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
5
|
+
|
6
|
+
## [Unreleased]
|
7
|
+
|
8
|
+
- Very basic understanding of how much memory it takes for a basic ruby app
|
9
|
+
- Start to integrate controllers and routing
|
10
|
+
- Add url params support to router
|
11
|
+
- Simple (not efficient) loop through routes and compare regex routing
|
12
|
+
- Bring in rack as a dependency in the gemspec
|
13
|
+
- Actually make everything work with rack for real
|
14
|
+
- Add an examples folder to test it out with real http requests!
|
15
|
+
- Add very simple ERB views
|
16
|
+
- Experiment with tilt
|
17
|
+
- Better view stuff, no tilt yet
|
18
|
+
- Use Rack::Request and Rack::Response
|
19
|
+
- Automatically render view from controller if a Rack::Response isn't returned
|
20
|
+
- Update tests
|
21
|
+
- Experiment with a module based mvc thing, didn't pan out
|
22
|
+
- Only allow resource routes, custom method names will not be found
|
23
|
+
- Add a bunch of rack middleware
|
24
|
+
- Move call to class
|
25
|
+
- Use Rack::Test
|
26
|
+
- Rename routes to resources; add resource method
|
27
|
+
- Add nested resources, multi-argument resources
|
data/Dockerfile
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
FROM docker.io/library/ruby:slim
|
2
|
+
|
3
|
+
RUN apt-get update -qq
|
4
|
+
RUN apt-get install -y build-essential libsqlite3-dev git-core
|
5
|
+
|
6
|
+
RUN apt-get install -y --no-install-recommends libjemalloc2
|
7
|
+
RUN rm -rf /var/lib/apt/lists/*
|
8
|
+
|
9
|
+
ENV LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2
|
10
|
+
|
11
|
+
ARG USER=app
|
12
|
+
ARG GROUP=app
|
13
|
+
ARG UID=1101
|
14
|
+
ARG GID=1101
|
15
|
+
|
16
|
+
RUN groupadd --gid $GID $GROUP
|
17
|
+
RUN useradd --uid $UID --gid $GID --groups $GROUP -ms /bin/bash $USER
|
18
|
+
|
19
|
+
RUN mkdir -p /var/app
|
20
|
+
RUN chown -R $USER:$GROUP /var/app
|
21
|
+
|
22
|
+
USER $USER
|
23
|
+
WORKDIR /var/app
|
24
|
+
|
25
|
+
ENV BUNDLER_VERSION='2.2.27'
|
26
|
+
RUN gem install bundler --no-document -v '2.2.27'
|
27
|
+
|
28
|
+
COPY --chown=$USER Gemfile* /var/app/
|
29
|
+
COPY --chown=$USER *.gemspec /var/app/
|
30
|
+
RUN bundle install
|
31
|
+
|
32
|
+
COPY --chown=$USER . /var/app
|
33
|
+
|
34
|
+
CMD ["rake"]
|
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2021 Sean Walker
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
# radical
|
2
|
+
|
3
|
+
A rails inspired ruby web framework
|
4
|
+
|
5
|
+
# Learning
|
6
|
+
|
7
|
+
Check out the `examples/` folder for some ideas on how to get started and when you're ready, check the `docs/` folder for more details.
|
8
|
+
|
9
|
+
# Quickstart
|
10
|
+
|
11
|
+
Create a directory with a `config.ru` and a `Gemfile` file inside of it
|
12
|
+
|
13
|
+
```sh
|
14
|
+
mkdir your_project
|
15
|
+
cd your_project
|
16
|
+
touch config.ru Gemfile
|
17
|
+
```
|
18
|
+
|
19
|
+
Put this inside of the `config.ru`
|
20
|
+
|
21
|
+
```rb
|
22
|
+
require 'radical'
|
23
|
+
|
24
|
+
class Home < Radical::Controller
|
25
|
+
def index
|
26
|
+
plain '/'
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
class App < Radical::App
|
31
|
+
root Home
|
32
|
+
end
|
33
|
+
|
34
|
+
run App
|
35
|
+
```
|
36
|
+
|
37
|
+
Install the gems and start the server
|
38
|
+
|
39
|
+
```sh
|
40
|
+
gem install radical puma
|
41
|
+
puma
|
42
|
+
```
|
43
|
+
|
44
|
+
Test it out
|
45
|
+
|
46
|
+
```sh
|
47
|
+
curl localhost:9292
|
48
|
+
# => /
|
49
|
+
```
|
data/Rakefile
ADDED
data/lib/radical/app.rb
ADDED
@@ -0,0 +1,103 @@
|
|
1
|
+
require 'rack'
|
2
|
+
require 'rack/flash'
|
3
|
+
require 'rack/csrf'
|
4
|
+
|
5
|
+
require_relative 'router'
|
6
|
+
require_relative 'env'
|
7
|
+
|
8
|
+
# The main entry point for a Radical application
|
9
|
+
#
|
10
|
+
# Example:
|
11
|
+
#
|
12
|
+
# class App < Radical::App
|
13
|
+
# root Home
|
14
|
+
# end
|
15
|
+
#
|
16
|
+
# App.call(
|
17
|
+
# {
|
18
|
+
# 'PATH_INFO' => '/',
|
19
|
+
# 'REQUEST_METHOD' => 'GET'
|
20
|
+
# }
|
21
|
+
# )
|
22
|
+
#
|
23
|
+
# Dispatches to:
|
24
|
+
#
|
25
|
+
# class Controller < Radical::Controller
|
26
|
+
# # GET /
|
27
|
+
# def index
|
28
|
+
# head :ok
|
29
|
+
# end
|
30
|
+
# end
|
31
|
+
module Radical
|
32
|
+
class App
|
33
|
+
class << self
|
34
|
+
def root(klass)
|
35
|
+
router.add_root(klass)
|
36
|
+
end
|
37
|
+
|
38
|
+
def resource(klass)
|
39
|
+
router.add_actions(klass, actions: Router::RESOURCE_ACTIONS)
|
40
|
+
end
|
41
|
+
|
42
|
+
def resources(*classes, &block)
|
43
|
+
prefix = "#{router.route_prefix(@parents)}/" if instance_variable_defined?(:@parents)
|
44
|
+
|
45
|
+
router.add_routes(classes, prefix: prefix)
|
46
|
+
|
47
|
+
return unless block
|
48
|
+
|
49
|
+
@parents ||= []
|
50
|
+
@parents << classes.last
|
51
|
+
block.call
|
52
|
+
end
|
53
|
+
|
54
|
+
def env
|
55
|
+
Env
|
56
|
+
end
|
57
|
+
|
58
|
+
def app
|
59
|
+
router = self.router
|
60
|
+
env = self.env
|
61
|
+
|
62
|
+
@app ||= Rack::Builder.app do
|
63
|
+
use Rack::CommonLogger
|
64
|
+
use Rack::ShowExceptions if env.development?
|
65
|
+
use Rack::Runtime
|
66
|
+
use Rack::MethodOverride
|
67
|
+
use Rack::ContentLength
|
68
|
+
use Rack::Deflater
|
69
|
+
use Rack::ETag
|
70
|
+
use Rack::Head
|
71
|
+
use Rack::ConditionalGet
|
72
|
+
use Rack::ContentType
|
73
|
+
use Rack::Session::Cookie, path: '/',
|
74
|
+
secret: ENV['SESSION_SECRET'],
|
75
|
+
http_only: true,
|
76
|
+
same_site: :lax,
|
77
|
+
secure: env.production?,
|
78
|
+
expire_after: 2_592_000 # 30 days
|
79
|
+
use Rack::Csrf, raise: env.development?, skip: router.routes.values.flatten.select { |a| a.is_a?(Class) }.uniq.map(&:skip_csrf_actions).flatten(1)
|
80
|
+
use Rack::Flash, sweep: true
|
81
|
+
|
82
|
+
run lambda { |rack_env|
|
83
|
+
begin
|
84
|
+
router.route(Rack::Request.new(rack_env)).finish
|
85
|
+
rescue ModelNotFound
|
86
|
+
raise unless env.production?
|
87
|
+
|
88
|
+
Rack::Response.new('404 Not Found', 404).finish
|
89
|
+
end
|
90
|
+
}
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def router
|
95
|
+
@router ||= Router.new
|
96
|
+
end
|
97
|
+
|
98
|
+
def call(env)
|
99
|
+
app.call(env)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,144 @@
|
|
1
|
+
# typed: true
|
2
|
+
require 'rack/utils'
|
3
|
+
require 'rack/request'
|
4
|
+
require 'rack/response'
|
5
|
+
require 'sorbet-runtime'
|
6
|
+
require_relative 'view'
|
7
|
+
require_relative 'env'
|
8
|
+
require_relative 'form'
|
9
|
+
|
10
|
+
module Radical
|
11
|
+
class Controller
|
12
|
+
extend T::Sig
|
13
|
+
|
14
|
+
attr_accessor :request
|
15
|
+
|
16
|
+
class << self
|
17
|
+
extend T::Sig
|
18
|
+
|
19
|
+
attr_accessor :skip_csrf_actions
|
20
|
+
|
21
|
+
sig { params(path: String).void }
|
22
|
+
def prepend_view_path(path)
|
23
|
+
View.path path
|
24
|
+
end
|
25
|
+
|
26
|
+
def layout(name)
|
27
|
+
View.layout name
|
28
|
+
end
|
29
|
+
|
30
|
+
sig { returns(String) }
|
31
|
+
def route_name
|
32
|
+
to_s.split('::').last.gsub(/Controller$/, '').gsub(/([A-Z])/, '_\1')[1..-1].downcase
|
33
|
+
end
|
34
|
+
|
35
|
+
sig { params(actions: Symbol).void }
|
36
|
+
def skip_csrf(*actions)
|
37
|
+
@skip_csrf_actions = [] if @skip_csrf_actions.nil?
|
38
|
+
|
39
|
+
actions.each do |action|
|
40
|
+
@skip_csrf_actions << "#{action_to_http_method(action)}:#{action_to_url(action)}"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
sig { params(action: Symbol).returns(String) }
|
45
|
+
def action_to_url(action)
|
46
|
+
case action
|
47
|
+
when :index, :create
|
48
|
+
"/#{route_name}"
|
49
|
+
when :show, :update, :destroy
|
50
|
+
"/#{route_name}/:id"
|
51
|
+
when :new
|
52
|
+
"/#{route_name}/new"
|
53
|
+
when :edit
|
54
|
+
"/#{route_name}/:id/edit"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
sig { params(action: Symbol).returns(String) }
|
59
|
+
def action_to_http_method(action)
|
60
|
+
case action
|
61
|
+
when :index, :show, :new, :edit
|
62
|
+
'GET'
|
63
|
+
when :create
|
64
|
+
'POST'
|
65
|
+
when :update
|
66
|
+
'PATCH'
|
67
|
+
when :destroy
|
68
|
+
'DELETE'
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
sig { params(request: Rack::Request).void }
|
74
|
+
def initialize(request)
|
75
|
+
@request = request
|
76
|
+
end
|
77
|
+
|
78
|
+
sig { params(status: T.any(Symbol, Integer)).returns(Rack::Response) }
|
79
|
+
def head(status)
|
80
|
+
Rack::Response.new(nil, Rack::Utils::SYMBOL_TO_STATUS_CODE[status])
|
81
|
+
end
|
82
|
+
|
83
|
+
sig { params(body: String).returns(Rack::Response) }
|
84
|
+
def plain(body)
|
85
|
+
Rack::Response.new(body, 200, { 'Content-Type' => 'text/plain' })
|
86
|
+
end
|
87
|
+
|
88
|
+
sig { returns(Hash) }
|
89
|
+
def params
|
90
|
+
@request.params
|
91
|
+
end
|
92
|
+
|
93
|
+
sig { params(name: T.any(String, Symbol)).returns(String) }
|
94
|
+
def view(name)
|
95
|
+
View.render(self.class.route_name, name, self)
|
96
|
+
end
|
97
|
+
|
98
|
+
sig { params(name: T.any(String, Symbol)).returns(String) }
|
99
|
+
def partial(name)
|
100
|
+
View.render(self.class.route_name, "_#{name}", self, layout: false)
|
101
|
+
end
|
102
|
+
|
103
|
+
sig { params(options: Hash, block: T.proc.void).returns(String) }
|
104
|
+
def form(options, &block)
|
105
|
+
f = Form.new(options, self)
|
106
|
+
|
107
|
+
capture(block) do
|
108
|
+
emit f.open_tag
|
109
|
+
emit f.csrf_tag
|
110
|
+
emit f.rack_override_tag
|
111
|
+
yield f
|
112
|
+
emit f.close_tag
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
sig { params(to: T.any(Symbol, String)).returns(Rack::Response) }
|
117
|
+
def redirect(to)
|
118
|
+
to = self.class.action_to_url(to) if to.is_a?(Symbol)
|
119
|
+
|
120
|
+
Rack::Response.new(nil, 302, { 'Location' => to })
|
121
|
+
end
|
122
|
+
|
123
|
+
def flash
|
124
|
+
@request.env['rack.session']['__FLASH__']
|
125
|
+
end
|
126
|
+
|
127
|
+
def session
|
128
|
+
@request.env['rack.session']
|
129
|
+
end
|
130
|
+
|
131
|
+
private
|
132
|
+
|
133
|
+
def emit(tag)
|
134
|
+
@output = '' if @output.nil?
|
135
|
+
@output << tag.to_s
|
136
|
+
end
|
137
|
+
|
138
|
+
def capture(block)
|
139
|
+
@output = eval('_buf', block.binding)
|
140
|
+
yield
|
141
|
+
@output
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
require 'sqlite3'
|
2
|
+
require_relative 'table'
|
3
|
+
|
4
|
+
module Radical
|
5
|
+
class Database
|
6
|
+
class << self
|
7
|
+
attr_accessor :connection, :migrations_path
|
8
|
+
|
9
|
+
def db
|
10
|
+
connection
|
11
|
+
end
|
12
|
+
|
13
|
+
def migrate!
|
14
|
+
@migrate = true
|
15
|
+
|
16
|
+
db.execute 'create table if not exists radical_migrations ( version integer primary key )'
|
17
|
+
|
18
|
+
pending_migrations.each do |migration|
|
19
|
+
puts "Executing migration #{migration}"
|
20
|
+
sql = eval File.read(migration)
|
21
|
+
db.execute sql
|
22
|
+
db.execute 'insert into radical_migrations (version) values (?)', [version(migration)]
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def rollback!
|
27
|
+
@rollback = true
|
28
|
+
|
29
|
+
db.execute 'create table if not exists radical_migrations ( version integer primary key )'
|
30
|
+
|
31
|
+
migration = applied_migrations.last
|
32
|
+
|
33
|
+
puts "Rolling back migration #{migration}"
|
34
|
+
|
35
|
+
sql = eval File.read(migration)
|
36
|
+
db.execute sql
|
37
|
+
db.execute 'delete from radical_migrations where version = ?', [version(migration)]
|
38
|
+
end
|
39
|
+
|
40
|
+
def applied_versions
|
41
|
+
sql = 'select * from radical_migrations order by version'
|
42
|
+
rows = db.execute sql
|
43
|
+
|
44
|
+
rows.map { |r| r['version'] }
|
45
|
+
end
|
46
|
+
|
47
|
+
def applied_migrations
|
48
|
+
migrations.select { |f| applied_versions.include?(version(f)) }
|
49
|
+
end
|
50
|
+
|
51
|
+
def pending_migrations
|
52
|
+
migrations.reject { |f| applied_versions.include?(version(f)) }
|
53
|
+
end
|
54
|
+
|
55
|
+
def migrations
|
56
|
+
Dir[File.join(migrations_path, 'db', 'migrations', '*.rb')].sort
|
57
|
+
end
|
58
|
+
|
59
|
+
def version(filename)
|
60
|
+
filename.split(File::SEPARATOR).last.split('_').first.to_i
|
61
|
+
end
|
62
|
+
|
63
|
+
def migration(&block)
|
64
|
+
block.call
|
65
|
+
end
|
66
|
+
|
67
|
+
def change(&block)
|
68
|
+
@change = true
|
69
|
+
|
70
|
+
block.call
|
71
|
+
end
|
72
|
+
|
73
|
+
def up(&block)
|
74
|
+
block.call
|
75
|
+
end
|
76
|
+
|
77
|
+
def down(&block)
|
78
|
+
block.call
|
79
|
+
end
|
80
|
+
|
81
|
+
def create_table(name, &block)
|
82
|
+
return "drop table #{name}" if @change && @rollback
|
83
|
+
|
84
|
+
table = Table.new(name)
|
85
|
+
|
86
|
+
block.call(table)
|
87
|
+
|
88
|
+
"create table #{name} ( id integer primary key, #{table.columns.join(',')} )"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
data/lib/radical/env.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
# typed: true
|
2
|
+
|
3
|
+
module Radical
|
4
|
+
class Env
|
5
|
+
class << self
|
6
|
+
def radical_env
|
7
|
+
ENV['RADICAL_ENV'] || ''
|
8
|
+
end
|
9
|
+
|
10
|
+
def development?
|
11
|
+
radical_env.downcase == 'development'
|
12
|
+
end
|
13
|
+
|
14
|
+
def test?
|
15
|
+
radical_env.downcase == 'test'
|
16
|
+
end
|
17
|
+
|
18
|
+
def production?
|
19
|
+
radical_env.downcase == 'production'
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
data/lib/radical/form.rb
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
require 'rack/csrf'
|
2
|
+
|
3
|
+
module Radical
|
4
|
+
class Form
|
5
|
+
def initialize(options, controller)
|
6
|
+
@model = options[:model]
|
7
|
+
@controller = controller
|
8
|
+
@route_name = @controller.class.route_name
|
9
|
+
@override_method = options[:method]&.upcase || (@model.saved? ? 'PATCH' : 'POST')
|
10
|
+
@method = %w[GET POST].include?(@override_method) ? @override_method : 'POST'
|
11
|
+
|
12
|
+
@action = if @model.saved?
|
13
|
+
@controller.public_send(:"#{@route_name}_path", @model)
|
14
|
+
else
|
15
|
+
@controller.public_send(:"#{@route_name}_path")
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def text(name)
|
20
|
+
"<input type=text name=#{@route_name}[#{name}] value=\"#{@model.public_send(name)}\" />"
|
21
|
+
end
|
22
|
+
|
23
|
+
def button(name)
|
24
|
+
"<button type=submit>#{name}</button>"
|
25
|
+
end
|
26
|
+
|
27
|
+
def submit(value)
|
28
|
+
"<input type=submit value=#{value} />"
|
29
|
+
end
|
30
|
+
|
31
|
+
def open_tag
|
32
|
+
"<form action=#{@action} method=#{@method}>"
|
33
|
+
end
|
34
|
+
|
35
|
+
def csrf_tag
|
36
|
+
Rack::Csrf.tag(@controller.request.env)
|
37
|
+
end
|
38
|
+
|
39
|
+
def rack_override_tag
|
40
|
+
"<input type=hidden name=_method value=#{@override_method} />" unless %w[GET POST].include?(@override_method)
|
41
|
+
end
|
42
|
+
|
43
|
+
def close_tag
|
44
|
+
'</form>'
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|