hamster-web 0.1.0.pre.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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +61 -0
- data/exe/hamster +24 -0
- data/lib/hamster/build/manifest.rb +53 -0
- data/lib/hamster/build/postgres_compatibility.rb +38 -0
- data/lib/hamster/build/query_accessors.rb +73 -0
- data/lib/hamster/build/view_compiler.rb +239 -0
- data/lib/hamster/cli.rb +166 -0
- data/lib/hamster/console.rb +34 -0
- data/lib/hamster/data/persistence.rb +152 -0
- data/lib/hamster/data/query.rb +106 -0
- data/lib/hamster/data/read/compiler.rb +101 -0
- data/lib/hamster/data/read/decoder.rb +101 -0
- data/lib/hamster/data/read/nodes.rb +118 -0
- data/lib/hamster/data/read/relation.rb +198 -0
- data/lib/hamster/data/read/schema.rb +40 -0
- data/lib/hamster/data/read/validator.rb +273 -0
- data/lib/hamster/data/record.rb +81 -0
- data/lib/hamster/data/table.rb +90 -0
- data/lib/hamster/data.rb +21 -0
- data/lib/hamster/database/admin.rb +39 -0
- data/lib/hamster/database/configuration.rb +84 -0
- data/lib/hamster/database/connection.rb +168 -0
- data/lib/hamster/database/database.rb +92 -0
- data/lib/hamster/database/errors.rb +21 -0
- data/lib/hamster/database/pool.rb +98 -0
- data/lib/hamster/database/postgres.rb +66 -0
- data/lib/hamster/database/result.rb +30 -0
- data/lib/hamster/schema_validation.rb +12 -0
- data/lib/hamster/web/action/base.rb +39 -0
- data/lib/hamster/web/application.rb +23 -0
- data/lib/hamster/web/context.rb +25 -0
- data/lib/hamster/web/html.rb +80 -0
- data/lib/hamster/web/http.rb +34 -0
- data/lib/hamster/web/instrumentation.rb +28 -0
- data/lib/hamster/web/router.rb +27 -0
- data/lib/hamster/web/routing/base.rb +131 -0
- data/lib/hamster/web/version.rb +7 -0
- data/lib/hamster/web/view/form.rb +119 -0
- data/lib/hamster/web/view.rb +196 -0
- data/lib/hamster/web.rb +13 -0
- data/templates/app/.gitignore +5 -0
- data/templates/app/AGENTS.md +8 -0
- data/templates/app/Gemfile +3 -0
- data/templates/app/README.md +32 -0
- data/templates/app/app/routes/home/index/action.rb +14 -0
- data/templates/app/app/routes/home/index/view.html.erb +2 -0
- data/templates/app/app/routes/mappings.rb +8 -0
- data/templates/app/app/views/layouts/application.html.erb +3 -0
- data/templates/app/bin/hamster +4 -0
- data/templates/app/bin/server +8 -0
- data/templates/app/config/application.rb +23 -0
- data/templates/app/config/commands.rb +16 -0
- data/templates/app/config/database.rb +20 -0
- data/templates/app/config/hamster.json +1 -0
- data/templates/app/db/migrations/.keep +0 -0
- data/templates/app/spin.lock +6 -0
- data/templates/app/spin.toml +2 -0
- metadata +133 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "socket"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "digest"
|
|
5
|
+
require_relative "../data"
|
|
6
|
+
|
|
7
|
+
if RUBY_ENGINE == "ruby"
|
|
8
|
+
require "openssl"
|
|
9
|
+
require "pg/client"
|
|
10
|
+
else
|
|
11
|
+
require "pg"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module Hamster
|
|
15
|
+
class Database
|
|
16
|
+
class Postgres
|
|
17
|
+
def self.connect(configuration)
|
|
18
|
+
if RUBY_ENGINE == "ruby"
|
|
19
|
+
transport = RubyTransport.new(configuration.host, configuration.port)
|
|
20
|
+
connection = PgClientCore.new(transport, configuration.username, configuration.database,
|
|
21
|
+
configuration.password, SecureRandom.urlsafe_base64(18))
|
|
22
|
+
connection.connect!
|
|
23
|
+
connection
|
|
24
|
+
else
|
|
25
|
+
PG.connect(configuration.host, configuration.port, configuration.database,
|
|
26
|
+
configuration.username, configuration.password)
|
|
27
|
+
end
|
|
28
|
+
rescue PG::Error => error
|
|
29
|
+
raise ConnectionError.new("postgres connection failed", original: error,
|
|
30
|
+
sqlstate: sqlstate(error))
|
|
31
|
+
rescue StandardError => error
|
|
32
|
+
raise ConnectionError.new("postgres connection failed: #{error.message}", original: error)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.sqlstate(error)
|
|
36
|
+
error.result.error_field(PG::PG_DIAG_SQLSTATE)
|
|
37
|
+
rescue StandardError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class RubyTransport
|
|
42
|
+
def initialize(host, port)
|
|
43
|
+
@socket = TCPSocket.new(host, port)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def write(data)
|
|
47
|
+
offset = 0
|
|
48
|
+
while offset < data.bytesize
|
|
49
|
+
offset += @socket.write(data.byteslice(offset, data.bytesize - offset))
|
|
50
|
+
end
|
|
51
|
+
offset
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def read_some(max)
|
|
55
|
+
@socket.readpartial(max)
|
|
56
|
+
rescue EOFError
|
|
57
|
+
""
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def close
|
|
61
|
+
@socket.close unless @socket.closed?
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
class Database
|
|
5
|
+
class Result
|
|
6
|
+
attr_reader :columns, :rows, :types, :command_tag
|
|
7
|
+
|
|
8
|
+
def initialize(columns:, rows:, types:, command_tag:)
|
|
9
|
+
@columns = columns
|
|
10
|
+
@rows = rows
|
|
11
|
+
@types = types
|
|
12
|
+
@command_tag = command_tag
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def each_row
|
|
16
|
+
return enum_for(:each_row) unless block_given?
|
|
17
|
+
|
|
18
|
+
rows.each { |row| yield row }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def hashes
|
|
22
|
+
rows.map do |row|
|
|
23
|
+
columns.each_with_index.each_with_object({}) do |(column, index), result|
|
|
24
|
+
result[column] = row[index]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative "data"
|
|
3
|
+
|
|
4
|
+
module Hamster
|
|
5
|
+
module SchemaValidation
|
|
6
|
+
def self.run(database, tables = Table.registry)
|
|
7
|
+
database.with_connection { |connection| Read::Schema.validate(connection, tables) }
|
|
8
|
+
ensure
|
|
9
|
+
database.shutdown
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
module Action
|
|
5
|
+
class Base
|
|
6
|
+
STATUSES = {ok: 200, see_other: 303, unauthorized: 401, forbidden: 403,
|
|
7
|
+
not_found: 404, method_not_allowed: 405, unprocessable_entity: 422,
|
|
8
|
+
upgrade_required: 426, service_unavailable: 503}.freeze
|
|
9
|
+
attr_reader :context
|
|
10
|
+
|
|
11
|
+
def self.status_code(status)
|
|
12
|
+
return status unless status.is_a?(Symbol)
|
|
13
|
+
STATUSES.fetch(status) { raise ArgumentError, "unknown HTTP status #{status.inspect}" }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(context)
|
|
17
|
+
@context = context
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def respond(view: nil, body: nil, locals: {}, layout: "layouts/application",
|
|
23
|
+
status: :ok, type: :html, headers: {})
|
|
24
|
+
raise ArgumentError, "respond requires exactly one of view: or body:" if view.nil? == body.nil?
|
|
25
|
+
raise ArgumentError, "respond locals: must be a Hash" unless locals.is_a?(Hash)
|
|
26
|
+
body = Hamster::View.render(view, locals, layout: layout) if view
|
|
27
|
+
Http.response(status, body, headers, type: type)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def redirect_to(path, cookie: nil)
|
|
31
|
+
Http.redirect(path, cookie: cookie)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def upgrade(handler)
|
|
35
|
+
[101, {"cache-control" => "no-store"}, handler]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
class Application
|
|
5
|
+
attr_reader :database
|
|
6
|
+
|
|
7
|
+
def initialize(routes:, database: nil)
|
|
8
|
+
@router = Router.new(routes)
|
|
9
|
+
@database = database
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(env)
|
|
13
|
+
return @router.call(env) unless database
|
|
14
|
+
database.with_connection { |connection| @router.call(env, connection) }
|
|
15
|
+
rescue StandardError
|
|
16
|
+
Http.response(500, "<!doctype html><title>Server error</title><h1>Server error</h1>")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def shutdown
|
|
20
|
+
database.shutdown if database
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
class Context
|
|
5
|
+
attr_reader :env, :connection
|
|
6
|
+
|
|
7
|
+
def initialize(env, connection = nil)
|
|
8
|
+
@env = env
|
|
9
|
+
@connection = connection
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def params
|
|
13
|
+
env.fetch("route_params", {})
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def form
|
|
17
|
+
@form ||= Http.parse_parameters(env.fetch("rack.input").read.to_s)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def same_origin?
|
|
21
|
+
expected = "#{env.fetch('rack.url_scheme', 'http')}://#{env.fetch('HTTP_HOST', '')}"
|
|
22
|
+
env["HTTP_ORIGIN"] == expected
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
module HTML
|
|
5
|
+
ATTRIBUTE_NAME = /\A[a-z][a-z0-9_:-]*\z/
|
|
6
|
+
TAG_NAME = /\A[a-z][a-z0-9-]*\z/
|
|
7
|
+
URL_ATTRIBUTES = %w[href src action formaction].freeze
|
|
8
|
+
VOID_TAGS = %w[area base br col embed hr img input link meta param source track wbr].freeze
|
|
9
|
+
|
|
10
|
+
class Fragment
|
|
11
|
+
attr_reader :html
|
|
12
|
+
|
|
13
|
+
def initialize(html)
|
|
14
|
+
@html = html.to_s.freeze
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_s
|
|
19
|
+
@html
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def +(other)
|
|
23
|
+
Fragment.new(@html + HTML.escape(other).to_s)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def escape(value)
|
|
30
|
+
return value if value.instance_of?(Fragment)
|
|
31
|
+
|
|
32
|
+
Fragment.new(value.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
33
|
+
.gsub('"', """).gsub("'", "'"))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Use only where a helper has built and reviewed safe markup from escaped values.
|
|
37
|
+
def raw_reviewed(html)
|
|
38
|
+
Fragment.new(html)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def join(parts)
|
|
42
|
+
Fragment.new(parts.map { |part| escape(part).to_s }.join)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def tag(name, content = nil, attributes = {})
|
|
46
|
+
name = name.to_s
|
|
47
|
+
raise ArgumentError, "invalid HTML tag" unless name.match?(TAG_NAME) && name != "script" && name != "style"
|
|
48
|
+
|
|
49
|
+
opening = "<#{name}#{attribute_text(attributes)}>"
|
|
50
|
+
return Fragment.new(opening) if VOID_TAGS.include?(name) && content.nil?
|
|
51
|
+
raise ArgumentError, "#{name} cannot contain content" if VOID_TAGS.include?(name)
|
|
52
|
+
|
|
53
|
+
Fragment.new("#{opening}#{escape(content)}</#{name}>")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def link_to(content, url, attributes = {})
|
|
57
|
+
raise ArgumentError, "link attributes cannot include href" if attributes.keys.any? { |key| key.to_s == "href" }
|
|
58
|
+
|
|
59
|
+
tag(:a, content, attributes.merge(href: url))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def attribute_text(attributes)
|
|
63
|
+
attributes.map do |key, value|
|
|
64
|
+
name = key.to_s.tr("_", "-")
|
|
65
|
+
if name == "data" || name == "aria"
|
|
66
|
+
raise ArgumentError, "#{name} must be a hash" unless value.instance_of?(Hash)
|
|
67
|
+
value.map { |nested, nested_value| attribute_text("#{name}-#{nested}" => nested_value) }.join
|
|
68
|
+
else
|
|
69
|
+
raise ArgumentError, "invalid HTML attribute #{name}" unless name.match?(ATTRIBUTE_NAME)
|
|
70
|
+
raise ArgumentError, "unsafe HTML attribute #{name}" if name.start_with?("on") || name == "style" || name == "srcdoc"
|
|
71
|
+
next "" if value.nil? || value == false
|
|
72
|
+
next " #{name}" if value == true
|
|
73
|
+
|
|
74
|
+
View.validate_url!(value, "tag", 1) if URL_ATTRIBUTES.include?(name)
|
|
75
|
+
" #{name}=\"#{escape(value.to_s)}\""
|
|
76
|
+
end
|
|
77
|
+
end.join
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
module Http
|
|
5
|
+
MIME = {html: "text/html; charset=utf-8", turbo: "text/html; charset=utf-8",
|
|
6
|
+
turbo_stream: "text/vnd.turbo-stream.html; charset=utf-8"}.freeze
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def parse_parameters(encoded)
|
|
10
|
+
encoded.split("&").each_with_object({}) do |part, values|
|
|
11
|
+
key, value = part.split("=", 2)
|
|
12
|
+
values[decode(key)] = decode(value.to_s)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def decode(value)
|
|
17
|
+
value.to_s.gsub("+", " ").gsub(/%([0-9A-Fa-f]{2})/) { [$1.to_i(16)].pack("C") }.force_encoding("UTF-8")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def response(status, body, headers = {}, type: :html)
|
|
21
|
+
content_type = MIME.fetch(type) { raise ArgumentError, "unknown response type #{type.inspect}" }
|
|
22
|
+
status = Action::Base.status_code(status)
|
|
23
|
+
defaults = {"content-type" => content_type, "cache-control" => "no-store"}
|
|
24
|
+
defaults["vary"] = "Accept, Turbo-Frame" if type != :html
|
|
25
|
+
[status, defaults.merge(headers), [body.to_s]]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def redirect(path, cookie: nil)
|
|
29
|
+
headers = {"location" => path, "cache-control" => "no-store"}
|
|
30
|
+
headers["set-cookie"] = cookie if cookie
|
|
31
|
+
[303, headers, [""]]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
# The web runtime works without a telemetry backend. The telemetry entry point
|
|
5
|
+
# can replace this boundary while preserving the instrumented block's result.
|
|
6
|
+
module Instrumentation
|
|
7
|
+
SQL_OPERATIONS = %w[SELECT INSERT UPDATE DELETE BEGIN COMMIT ROLLBACK CREATE ALTER DROP TRUNCATE WITH SAVEPOINT RELEASE].freeze
|
|
8
|
+
|
|
9
|
+
def self.sql_operation(sql)
|
|
10
|
+
operation = sql.lstrip.split(/\s+/, 2).first.to_s.upcase
|
|
11
|
+
SQL_OPERATIONS.include?(operation) ? operation : "OTHER"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.diagnostic
|
|
15
|
+
$stderr.write("HAMSTER_TELEMETRY_FAILED\n")
|
|
16
|
+
rescue StandardError
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.instrument(name, attributes: {}, kind: "internal")
|
|
21
|
+
yield({})
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.record(name, operation)
|
|
25
|
+
instrument("record.#{operation}", attributes: {"record.name" => name}) { yield }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
class Router
|
|
5
|
+
NOT_FOUND = "<!doctype html><title>Not found</title><h1>Not found</h1>".freeze
|
|
6
|
+
METHOD_NOT_ALLOWED = "<!doctype html><title>Method not allowed</title><h1>Method not allowed</h1>".freeze
|
|
7
|
+
|
|
8
|
+
def initialize(mapping)
|
|
9
|
+
@routes = mapping.routes
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(env, connection = nil)
|
|
13
|
+
method = env.fetch("REQUEST_METHOD", "GET").upcase
|
|
14
|
+
path = env.fetch("PATH_INFO", "/")
|
|
15
|
+
route = @routes.find { |candidate| candidate.match(method, path) }
|
|
16
|
+
unless route
|
|
17
|
+
allowed = @routes.select { |candidate| candidate.match(method, path, method_only: true) }
|
|
18
|
+
return Http.response(:method_not_allowed, METHOD_NOT_ALLOWED,
|
|
19
|
+
{"allow" => allowed.map(&:method).uniq.join(", ")}) unless allowed.empty?
|
|
20
|
+
return Http.response(:not_found, NOT_FOUND)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
env["route_params"] = route.match(method, path)
|
|
24
|
+
route.action.new(Context.new(env, connection)).call
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
module Routing
|
|
5
|
+
class Route
|
|
6
|
+
attr_reader :name, :method, :pattern, :action
|
|
7
|
+
|
|
8
|
+
def initialize(name, method, pattern, action)
|
|
9
|
+
@name, @method, @pattern, @action = name, method, pattern, action
|
|
10
|
+
@segments = pattern.split("/").reject(&:empty?)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def match(request_method, path, method_only: false)
|
|
14
|
+
return nil unless method_only || method == request_method
|
|
15
|
+
|
|
16
|
+
path_segments = path.split("/").reject(&:empty?)
|
|
17
|
+
return nil unless path_segments.length == @segments.length
|
|
18
|
+
|
|
19
|
+
params = {}
|
|
20
|
+
@segments.zip(path_segments).each do |route_segment, path_segment|
|
|
21
|
+
if route_segment.start_with?(":")
|
|
22
|
+
return nil if route_segment == ":id" && !path_segment.match?(/\A[0-9]+\z/)
|
|
23
|
+
params[route_segment.delete_prefix(":")] = path_segment
|
|
24
|
+
elsif route_segment != path_segment
|
|
25
|
+
return nil
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
params
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def path(params = {}, query: {}, **keywords)
|
|
32
|
+
params = params.merge(keywords)
|
|
33
|
+
required = @segments.select { |segment| segment.start_with?(":") }.map { |segment| segment.delete_prefix(":") }
|
|
34
|
+
supplied = params.keys.map(&:to_s)
|
|
35
|
+
missing, extra = required - supplied, supplied - required
|
|
36
|
+
raise ArgumentError, "#{name}: missing path parameter #{missing.join(', ')}" unless missing.empty?
|
|
37
|
+
raise ArgumentError, "#{name}: unknown path parameter #{extra.join(', ')}" unless extra.empty?
|
|
38
|
+
|
|
39
|
+
segments = @segments.map do |segment|
|
|
40
|
+
next segment unless segment.start_with?(":")
|
|
41
|
+
key = segment.delete_prefix(":")
|
|
42
|
+
value = params.key?(key.to_sym) ? params[key.to_sym] : params[key]
|
|
43
|
+
raise ArgumentError, "#{name}: empty path parameter #{key}" if value.nil? || value.to_s.empty?
|
|
44
|
+
raise ArgumentError, "#{name}: #{key} must be numeric" if key == "id" && !value.to_s.match?(/\A[0-9]+\z/)
|
|
45
|
+
self.class.encode(value)
|
|
46
|
+
end
|
|
47
|
+
result = "/#{segments.join('/')}"
|
|
48
|
+
query.empty? ? result : "#{result}?#{query.map { |key, value| "#{self.class.encode(key)}=#{self.class.encode(value)}" }.join('&')}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.encode(value)
|
|
52
|
+
value.to_s.bytes.map do |byte|
|
|
53
|
+
if (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || (byte >= 48 && byte <= 57) || [45, 46, 95, 126].include?(byte)
|
|
54
|
+
byte.chr
|
|
55
|
+
else
|
|
56
|
+
"%#{byte.to_s(16).upcase.rjust(2, '0')}"
|
|
57
|
+
end
|
|
58
|
+
end.join
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class Base
|
|
63
|
+
SUPPORTED_METHODS = %w[GET POST PATCH DELETE].freeze
|
|
64
|
+
CONVENTIONAL_ACTIONS = {"New" => "new", "Edit" => "edit", "Create" => "create", "Update" => "update", "Destroy" => "destroy"}.freeze
|
|
65
|
+
|
|
66
|
+
def self.routes
|
|
67
|
+
(@routes ||= []).dup.freeze
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.get(path, to:, as: nil); add_route("GET", path, to, as); end
|
|
71
|
+
def self.post(path, to:, as: nil); add_route("POST", path, to, as); end
|
|
72
|
+
def self.patch(path, to:, as: nil); add_route("PATCH", path, to, as); end
|
|
73
|
+
def self.delete(path, to:, as: nil); add_route("DELETE", path, to, as); end
|
|
74
|
+
|
|
75
|
+
def self.namespace(name, &block)
|
|
76
|
+
raise ArgumentError, "namespace requires a block" unless block
|
|
77
|
+
segment = name.to_s
|
|
78
|
+
raise ArgumentError, "invalid namespace #{name.inspect}" unless segment.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
79
|
+
scopes = @route_scopes ||= []
|
|
80
|
+
scopes << segment
|
|
81
|
+
block.call
|
|
82
|
+
ensure
|
|
83
|
+
scopes.pop if scopes && !scopes.empty?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.add_route(method, path, action, name)
|
|
87
|
+
raise ArgumentError, "unsupported HTTP method #{method}" unless SUPPORTED_METHODS.include?(method)
|
|
88
|
+
raise ArgumentError, "route path must be a literal absolute path" unless path.is_a?(String) && path.start_with?("/") && !path.include?("?")
|
|
89
|
+
raise ArgumentError, "route action must be a class" unless action.is_a?(Class)
|
|
90
|
+
route_table = @routes ||= []
|
|
91
|
+
prefix = (@route_scopes || []).join("_")
|
|
92
|
+
path_prefix = (@route_scopes || []).join("/")
|
|
93
|
+
path = "/#{path_prefix}#{path == "/" ? "" : path}" unless path_prefix.empty?
|
|
94
|
+
name = name || infer_name(method, path, action)
|
|
95
|
+
name = "#{prefix}_#{name}".to_sym unless prefix.empty?
|
|
96
|
+
raise ArgumentError, "route name must be a Symbol" unless name.is_a?(Symbol)
|
|
97
|
+
raise ArgumentError, "duplicate route name #{name}" if route_table.any? { |route| route.name == name }
|
|
98
|
+
|
|
99
|
+
route_table << Route.new(name, method, path, action)
|
|
100
|
+
route_table.last
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.infer_name(method, path, action)
|
|
104
|
+
parts = path.split("/").reject(&:empty?)
|
|
105
|
+
return :root if parts.empty?
|
|
106
|
+
action_parts = action.name.to_s.split("::")
|
|
107
|
+
action_name = action_parts[-1] == "Action" ? action_parts[-2] : action_parts.last
|
|
108
|
+
resource = parts.find { |part| !part.start_with?(":") }
|
|
109
|
+
resource = resource.sub(/s\z/, "") if parts.any? { |part| part.start_with?(":") } || CONVENTIONAL_ACTIONS.key?(action_name)
|
|
110
|
+
conventional = CONVENTIONAL_ACTIONS[action_name]
|
|
111
|
+
return "#{conventional}_#{resource}".to_sym if conventional && resource
|
|
112
|
+
return resource.to_sym if method == "GET" && parts.length == 1 && !parts.first.start_with?(":")
|
|
113
|
+
return resource.to_sym if method == "GET" && parts.length == 2 && parts.last.start_with?(":")
|
|
114
|
+
container = action_name || "route"
|
|
115
|
+
container.gsub(/([a-z0-9])([A-Z])/, '\\1_\\2').downcase.to_sym
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.path(name, params = {}, query: {}, **keywords)
|
|
119
|
+
route = routes.find { |candidate| candidate.name == name.to_sym }
|
|
120
|
+
raise ArgumentError, "unknown route #{name}" unless route
|
|
121
|
+
route.path(params, query: query, **keywords)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.url(name, params = {}, query: {}, base_url:, **keywords)
|
|
125
|
+
base = base_url.to_s.sub(%r{/+\z}, "")
|
|
126
|
+
raise ArgumentError, "base_url must be an absolute URL" unless base.match?(%r{\Ahttps?://[^/]+})
|
|
127
|
+
"#{base}#{path(name, params, query: query, **keywords)}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
module View
|
|
5
|
+
class Form
|
|
6
|
+
METHODS = %w[GET POST PATCH DELETE].freeze
|
|
7
|
+
FIELD = /\A[a-z][a-z0-9_]*\z/
|
|
8
|
+
|
|
9
|
+
def initialize(action:, method: "POST", csrf_token: nil, values: {}, errors: {}, namespace: nil)
|
|
10
|
+
@action = action.to_s
|
|
11
|
+
View.validate_url!(@action, "form", 1)
|
|
12
|
+
@method = method.to_s.upcase
|
|
13
|
+
raise ArgumentError, "unsupported form method #{@method}" unless METHODS.include?(@method)
|
|
14
|
+
if @method != "GET" && csrf_token.to_s.empty?
|
|
15
|
+
raise ArgumentError, "CSRF token is required for #{@method} forms"
|
|
16
|
+
end
|
|
17
|
+
@csrf_token = csrf_token
|
|
18
|
+
@values = values
|
|
19
|
+
@errors = errors
|
|
20
|
+
raise ArgumentError, "invalid form namespace" if namespace && !namespace.to_s.match?(FIELD)
|
|
21
|
+
@namespace = namespace
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def start(attributes = {})
|
|
25
|
+
raise ArgumentError, "form attributes cannot override action or method" if attributes.keys.any? { |key| %w[action method].include?(key.to_s) }
|
|
26
|
+
|
|
27
|
+
form_attributes = {method: @method == "GET" ? "get" : "post", action: @action}.merge(attributes)
|
|
28
|
+
# The fixed form tag and validated attributes are safe to mark as HTML.
|
|
29
|
+
opening = HTML.raw_reviewed("<form#{HTML.attribute_text(form_attributes)}>")
|
|
30
|
+
return opening if @method == "GET"
|
|
31
|
+
|
|
32
|
+
fields = [opening, HTML.tag(:input, nil, type: "hidden", name: "csrf_token", value: @csrf_token)]
|
|
33
|
+
fields << HTML.tag(:input, nil, type: "hidden", name: "_method", value: @method) unless @method == "POST"
|
|
34
|
+
HTML.join(fields)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def text(name, attributes = {})
|
|
38
|
+
reject_value_attribute!(attributes)
|
|
39
|
+
type = attributes.fetch(:type, "text").to_s
|
|
40
|
+
field = field_attributes(name, attributes.merge(type: type))
|
|
41
|
+
field[:value] = value_for(name) unless type == "password"
|
|
42
|
+
HTML.tag(:input, nil, field)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def textarea(name, attributes = {})
|
|
46
|
+
reject_value_attribute!(attributes)
|
|
47
|
+
HTML.tag(:textarea, value_for(name), field_attributes(name, attributes))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def hidden(name, value: nil)
|
|
51
|
+
actual = value.nil? ? value_for(name) : value
|
|
52
|
+
HTML.tag(:input, nil, name: field_name(name), id: field_id(name), type: "hidden", value: actual)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def checkbox(name, value: "1", attributes: {})
|
|
56
|
+
reject_value_attribute!(attributes)
|
|
57
|
+
checked = value_for(name) == true || value_for(name).to_s == value.to_s
|
|
58
|
+
HTML.tag(:input, nil, field_attributes(name, attributes.merge(type: "checkbox", value: value, checked: checked)))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def select(name, options, attributes = {})
|
|
62
|
+
selected = value_for(name).to_s
|
|
63
|
+
choices = options.map do |label, value|
|
|
64
|
+
HTML.tag(:option, label, value: value, selected: value.to_s == selected)
|
|
65
|
+
end
|
|
66
|
+
HTML.tag(:select, HTML.join(choices), field_attributes(name, attributes))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def submit(label, attributes = {})
|
|
70
|
+
HTML.tag(:button, label, attributes.merge(type: "submit"))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def error(name)
|
|
74
|
+
message = error_for(name)
|
|
75
|
+
return HTML.join([]) if message.nil? || message.to_s.empty?
|
|
76
|
+
|
|
77
|
+
HTML.tag(:p, message, id: "#{field_id(name)}-error", class: "error", role: "alert")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def field_id(name)
|
|
81
|
+
base = field_name(name).tr("_", "-")
|
|
82
|
+
@namespace ? "#{@namespace}-#{base}" : base
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def field_name(name)
|
|
88
|
+
text = name.to_s
|
|
89
|
+
raise ArgumentError, "invalid form field #{text}" unless text.match?(FIELD)
|
|
90
|
+
|
|
91
|
+
text
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def field_attributes(name, attributes)
|
|
95
|
+
raise ArgumentError, "field attributes cannot override name or id" if attributes.keys.any? { |key| %w[name id].include?(key.to_s) }
|
|
96
|
+
|
|
97
|
+
field = {name: field_name(name), id: field_id(name)}.merge(attributes)
|
|
98
|
+
message = error_for(name)
|
|
99
|
+
unless message.nil? || message.to_s.empty?
|
|
100
|
+
aria = attributes.fetch(:aria, {}).merge(invalid: "true", describedby: "#{field_id(name)}-error")
|
|
101
|
+
field[:aria] = aria
|
|
102
|
+
end
|
|
103
|
+
field
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def value_for(name)
|
|
107
|
+
@values.key?(name.to_sym) ? @values[name.to_sym] : @values[name.to_s]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def error_for(name)
|
|
111
|
+
@errors.key?(name.to_sym) ? @errors[name.to_sym] : @errors[name.to_s]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def reject_value_attribute!(attributes)
|
|
115
|
+
raise ArgumentError, "pass form values to Form.new" if attributes.keys.any? { |key| key.to_s == "value" }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|