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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +61 -0
  4. data/exe/hamster +24 -0
  5. data/lib/hamster/build/manifest.rb +53 -0
  6. data/lib/hamster/build/postgres_compatibility.rb +38 -0
  7. data/lib/hamster/build/query_accessors.rb +73 -0
  8. data/lib/hamster/build/view_compiler.rb +239 -0
  9. data/lib/hamster/cli.rb +166 -0
  10. data/lib/hamster/console.rb +34 -0
  11. data/lib/hamster/data/persistence.rb +152 -0
  12. data/lib/hamster/data/query.rb +106 -0
  13. data/lib/hamster/data/read/compiler.rb +101 -0
  14. data/lib/hamster/data/read/decoder.rb +101 -0
  15. data/lib/hamster/data/read/nodes.rb +118 -0
  16. data/lib/hamster/data/read/relation.rb +198 -0
  17. data/lib/hamster/data/read/schema.rb +40 -0
  18. data/lib/hamster/data/read/validator.rb +273 -0
  19. data/lib/hamster/data/record.rb +81 -0
  20. data/lib/hamster/data/table.rb +90 -0
  21. data/lib/hamster/data.rb +21 -0
  22. data/lib/hamster/database/admin.rb +39 -0
  23. data/lib/hamster/database/configuration.rb +84 -0
  24. data/lib/hamster/database/connection.rb +168 -0
  25. data/lib/hamster/database/database.rb +92 -0
  26. data/lib/hamster/database/errors.rb +21 -0
  27. data/lib/hamster/database/pool.rb +98 -0
  28. data/lib/hamster/database/postgres.rb +66 -0
  29. data/lib/hamster/database/result.rb +30 -0
  30. data/lib/hamster/schema_validation.rb +12 -0
  31. data/lib/hamster/web/action/base.rb +39 -0
  32. data/lib/hamster/web/application.rb +23 -0
  33. data/lib/hamster/web/context.rb +25 -0
  34. data/lib/hamster/web/html.rb +80 -0
  35. data/lib/hamster/web/http.rb +34 -0
  36. data/lib/hamster/web/instrumentation.rb +28 -0
  37. data/lib/hamster/web/router.rb +27 -0
  38. data/lib/hamster/web/routing/base.rb +131 -0
  39. data/lib/hamster/web/version.rb +7 -0
  40. data/lib/hamster/web/view/form.rb +119 -0
  41. data/lib/hamster/web/view.rb +196 -0
  42. data/lib/hamster/web.rb +13 -0
  43. data/templates/app/.gitignore +5 -0
  44. data/templates/app/AGENTS.md +8 -0
  45. data/templates/app/Gemfile +3 -0
  46. data/templates/app/README.md +32 -0
  47. data/templates/app/app/routes/home/index/action.rb +14 -0
  48. data/templates/app/app/routes/home/index/view.html.erb +2 -0
  49. data/templates/app/app/routes/mappings.rb +8 -0
  50. data/templates/app/app/views/layouts/application.html.erb +3 -0
  51. data/templates/app/bin/hamster +4 -0
  52. data/templates/app/bin/server +8 -0
  53. data/templates/app/config/application.rb +23 -0
  54. data/templates/app/config/commands.rb +16 -0
  55. data/templates/app/config/database.rb +20 -0
  56. data/templates/app/config/hamster.json +1 -0
  57. data/templates/app/db/migrations/.keep +0 -0
  58. data/templates/app/spin.lock +6 -0
  59. data/templates/app/spin.toml +2 -0
  60. 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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
33
+ .gsub('"', "&quot;").gsub("'", "&#39;"))
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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hamster
4
+ module Web
5
+ VERSION = "0.1.0.pre.2"
6
+ end
7
+ 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