radical 1.0.0
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.
- 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
|