gemstack 0.2.5 → 0.3.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.
Files changed (199) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +7 -3
  4. data/lib/gemstack/cache/memory_store.rb +69 -0
  5. data/lib/gemstack/cache/null_store.rb +18 -0
  6. data/lib/gemstack/cache/redis_store.rb +70 -0
  7. data/lib/gemstack/cache/store.rb +89 -0
  8. data/lib/gemstack/cache.rb +61 -0
  9. data/lib/gemstack/cli/add_generator.rb +258 -0
  10. data/lib/gemstack/cli/app_generator.rb +142 -0
  11. data/lib/gemstack/cli/commands/db.rb +136 -0
  12. data/lib/gemstack/cli/commands/jobs.rb +96 -0
  13. data/lib/gemstack/cli/controller_generator.rb +73 -0
  14. data/lib/gemstack/cli/deploy_generator.rb +102 -0
  15. data/lib/gemstack/cli/doctor/upgrade_check.rb +32 -0
  16. data/lib/gemstack/cli/doctor.rb +311 -0
  17. data/lib/gemstack/cli/generator.rb +161 -0
  18. data/lib/gemstack/cli/job_generator.rb +48 -0
  19. data/lib/gemstack/cli/migration_generator.rb +52 -0
  20. data/lib/gemstack/cli/policy_generator.rb +35 -0
  21. data/lib/gemstack/cli/project.rb +53 -0
  22. data/lib/gemstack/cli/resource_generator.rb +170 -0
  23. data/lib/gemstack/cli/resource_spec.rb +95 -0
  24. data/lib/gemstack/cli.rb +304 -0
  25. data/lib/gemstack/contract/builder.rb +168 -0
  26. data/lib/gemstack/contract/docs/index.html +264 -0
  27. data/lib/gemstack/contract/docs.rb +45 -0
  28. data/lib/gemstack/contract/openapi.rb +123 -0
  29. data/lib/gemstack/contract/typescript.rb +162 -0
  30. data/lib/gemstack/contract.rb +93 -0
  31. data/lib/gemstack/core.rb +129 -0
  32. data/lib/gemstack/db/configuration.rb +148 -0
  33. data/lib/gemstack/db/errors.rb +137 -0
  34. data/lib/gemstack/db/json_compat.rb +21 -0
  35. data/lib/gemstack/db/migrator.rb +80 -0
  36. data/lib/gemstack/db/model.rb +226 -0
  37. data/lib/gemstack/db/schema_types.rb +28 -0
  38. data/lib/gemstack/db/tasks.rb +94 -0
  39. data/lib/gemstack/db/testing.rb +99 -0
  40. data/lib/gemstack/db.rb +210 -0
  41. data/lib/gemstack/dev/file_watcher.rb +58 -0
  42. data/lib/gemstack/dev/gateway.rb +220 -0
  43. data/lib/gemstack/dev/managed_process.rb +96 -0
  44. data/lib/gemstack/dev/ports.rb +26 -0
  45. data/lib/gemstack/dev/supervisor.rb +267 -0
  46. data/lib/gemstack/dev/terminal.rb +40 -0
  47. data/lib/gemstack/dev/toolchain.rb +86 -0
  48. data/lib/gemstack/dev.rb +43 -0
  49. data/lib/gemstack/dotenv.rb +61 -0
  50. data/lib/gemstack/environment.rb +37 -0
  51. data/lib/gemstack/error_mapping.rb +37 -0
  52. data/lib/gemstack/errors.rb +100 -0
  53. data/lib/gemstack/http/app.rb +30 -0
  54. data/lib/gemstack/http/config.rb +84 -0
  55. data/lib/gemstack/http/controller.rb +343 -0
  56. data/lib/gemstack/http/error_page.rb +111 -0
  57. data/lib/gemstack/http/error_renderer.rb +63 -0
  58. data/lib/gemstack/http/json_codec.rb +114 -0
  59. data/lib/gemstack/http/middleware/body_limit.rb +69 -0
  60. data/lib/gemstack/http/middleware/compression.rb +127 -0
  61. data/lib/gemstack/http/middleware/cors.rb +77 -0
  62. data/lib/gemstack/http/middleware/error_handler.rb +39 -0
  63. data/lib/gemstack/http/middleware/etags.rb +24 -0
  64. data/lib/gemstack/http/middleware/health_check.rb +28 -0
  65. data/lib/gemstack/http/middleware/request_id.rb +31 -0
  66. data/lib/gemstack/http/middleware/request_logger.rb +39 -0
  67. data/lib/gemstack/http/middleware/security_headers.rb +31 -0
  68. data/lib/gemstack/http/middleware_stack.rb +96 -0
  69. data/lib/gemstack/http/page.rb +36 -0
  70. data/lib/gemstack/http/params.rb +140 -0
  71. data/lib/gemstack/http/request.rb +64 -0
  72. data/lib/gemstack/http/router.rb +315 -0
  73. data/lib/gemstack/http.rb +41 -0
  74. data/lib/gemstack/inflector.rb +133 -0
  75. data/lib/gemstack/job.rb +154 -0
  76. data/lib/gemstack/jobs/adapters/async.rb +94 -0
  77. data/lib/gemstack/jobs/adapters/database.rb +188 -0
  78. data/lib/gemstack/jobs/adapters/inline.rb +34 -0
  79. data/lib/gemstack/jobs/adapters/sidekiq.rb +65 -0
  80. data/lib/gemstack/jobs/adapters/test.rb +59 -0
  81. data/lib/gemstack/jobs/executor.rb +70 -0
  82. data/lib/gemstack/jobs/testing.rb +55 -0
  83. data/lib/gemstack/jobs/worker.rb +137 -0
  84. data/lib/gemstack/jobs.rb +144 -0
  85. data/lib/gemstack/logger.rb +131 -0
  86. data/lib/gemstack/mail/delivery_job.rb +20 -0
  87. data/lib/gemstack/mail/testing.rb +33 -0
  88. data/lib/gemstack/mail.rb +230 -0
  89. data/lib/gemstack/plugins.rb +38 -0
  90. data/lib/gemstack/schema.rb +251 -0
  91. data/lib/gemstack/serializer.rb +186 -0
  92. data/lib/gemstack/settings.rb +86 -0
  93. data/lib/gemstack/storage/endpoint.rb +112 -0
  94. data/lib/gemstack/storage/services/disk.rb +60 -0
  95. data/lib/gemstack/storage/services/s3.rb +65 -0
  96. data/lib/gemstack/storage/testing.rb +30 -0
  97. data/lib/gemstack/storage.rb +183 -0
  98. data/lib/gemstack/types.rb +163 -0
  99. data/lib/gemstack/version.rb +6 -0
  100. data/lib/gemstack.rb +1 -1
  101. data/templates/app/Gemfile.tt +27 -0
  102. data/templates/app/README.md.tt +29 -0
  103. data/templates/app/app/controllers/application_controller.rb +6 -0
  104. data/templates/app/app/jobs/application_job.rb +9 -0
  105. data/templates/app/app/mailers/application_mailer.rb +8 -0
  106. data/templates/app/app/mailers/templates/dot_keep +0 -0
  107. data/templates/app/app/models/application_model.rb +19 -0
  108. data/templates/app/app/serializers/application_serializer.rb +6 -0
  109. data/templates/app/bin/gemstack +7 -0
  110. data/templates/app/config/app.rb.tt +60 -0
  111. data/templates/app/config/database.yml.tt +61 -0
  112. data/templates/app/config/environments/development.rb.tt +35 -0
  113. data/templates/app/config/environments/production.rb.tt +40 -0
  114. data/templates/app/config/environments/test.rb.tt +21 -0
  115. data/templates/app/config/puma.rb +21 -0
  116. data/templates/app/config/routes.rb +10 -0
  117. data/templates/app/config.ru +6 -0
  118. data/templates/app/db/migrations/dot_keep +0 -0
  119. data/templates/app/db/seeds.rb +5 -0
  120. data/templates/app/dot_env.example.tt +19 -0
  121. data/templates/app/dot_gitignore +16 -0
  122. data/templates/app/dot_node-version.tt +1 -0
  123. data/templates/app/dot_nvmrc.tt +1 -0
  124. data/templates/app/dot_ruby-version.tt +1 -0
  125. data/templates/app/dot_tool-versions.tt +4 -0
  126. data/templates/app/test/health_test.rb +18 -0
  127. data/templates/app/test/test_helper.rb.tt +25 -0
  128. data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
  129. data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
  130. data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
  131. data/templates/auth/app/controllers/registrations_controller.rb +19 -0
  132. data/templates/auth/app/controllers/sessions_controller.rb +29 -0
  133. data/templates/auth/app/mailers/auth_mailer.rb +20 -0
  134. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
  135. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
  136. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
  137. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
  138. data/templates/auth/app/models/auth_token.rb +15 -0
  139. data/templates/auth/app/models/user.rb +12 -0
  140. data/templates/auth/app/policies/application_policy.rb +6 -0
  141. data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
  142. data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
  143. data/templates/auth/app/serializers/user_serializer.rb +6 -0
  144. data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
  145. data/templates/auth/frontend/app/account/page.tsx +116 -0
  146. data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
  147. data/templates/auth/frontend/app/login/page.tsx +26 -0
  148. data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
  149. data/templates/auth/frontend/app/signup/page.tsx +27 -0
  150. data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
  151. data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
  152. data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
  153. data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
  154. data/templates/auth/frontend/lib/auth.ts +113 -0
  155. data/templates/auth/test/controllers/auth_test.rb +108 -0
  156. data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
  157. data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
  158. data/templates/deploy/Caddyfile.tt +19 -0
  159. data/templates/deploy/Dockerfile.tt +63 -0
  160. data/templates/deploy/Procfile.tt +6 -0
  161. data/templates/deploy/compose.yaml.tt +129 -0
  162. data/templates/deploy/dot_dockerignore +14 -0
  163. data/templates/frontend/app/globals.css +171 -0
  164. data/templates/frontend/app/layout.tsx.tt +19 -0
  165. data/templates/frontend/app/page.module.css +315 -0
  166. data/templates/frontend/app/page.tsx.tt +183 -0
  167. data/templates/frontend/app/providers.tsx +24 -0
  168. data/templates/frontend/lib/gemstack/client.ts +125 -0
  169. data/templates/frontend/next-env.d.ts +5 -0
  170. data/templates/frontend/next.config.ts +17 -0
  171. data/templates/frontend/package.json.tt +23 -0
  172. data/templates/frontend/tsconfig.json +21 -0
  173. data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
  174. data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
  175. data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
  176. data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
  177. data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
  178. data/templates/realtime/config/channels.rb +14 -0
  179. data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
  180. data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
  181. data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
  182. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
  183. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
  184. data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
  185. data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
  186. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
  187. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
  188. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
  189. data/templates/resource/frontend/frontend/lib/format.ts +12 -0
  190. data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +64 -0
  191. data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
  192. data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
  193. data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
  194. data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
  195. data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
  196. data/templates/storage/app/serializers/upload_serializer.rb +11 -0
  197. data/templates/storage/frontend/lib/upload.ts +52 -0
  198. data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
  199. metadata +251 -39
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "securerandom"
5
+ require "json"
6
+ require "time"
7
+ require "gemstack/core"
8
+ require "gemstack/http"
9
+
10
+ module GemStack
11
+ # File storage: files go from the browser straight to
12
+ # the storage service, never through a Ruby process.
13
+ #
14
+ # # POST /api/uploads → presigned upload for the browser
15
+ # upload = GemStack::Storage.direct_upload(filename: "cat.png", content_type: "image/png", byte_size: 52_000)
16
+ # # browser: PUT the file to upload[:url] with upload[:headers]
17
+ # # later: product.update(image_key: GemStack::Storage.key_for_signed_id(params[:image]))
18
+ # GemStack::Storage.url(product.image_key) # short-lived signed download URL
19
+ #
20
+ # Services: :disk (development and tests; files under storage/, served and
21
+ # accepted by a signed endpoint at /api/storage) and :s3 (S3, R2, MinIO…).
22
+ module Storage
23
+ class Config < Settings
24
+ # :disk, :s3, or a service object.
25
+ setting :service, default: -> { ENV.fetch("STORAGE_SERVICE", GemStack.env.production? ? "s3" : "disk").to_sym }
26
+ setting :root, default: -> { File.join(GemStack.config.root, "storage", GemStack.env.test? ? "test" : "") }
27
+ # Where the disk service's signed URLs point (handled by Storage::Endpoint).
28
+ setting :path, default: -> { "#{GemStack.config.http.api_path}/storage" }
29
+ setting :bucket, default: -> { ENV.fetch("S3_BUCKET", nil) }
30
+ setting :region, default: -> { ENV.fetch("AWS_REGION", ENV.fetch("S3_REGION", "us-east-1")) }
31
+ # For S3-compatible services: https://<account>.r2.cloudflarestorage.com, http://localhost:9000
32
+ setting :endpoint, default: -> { ENV.fetch("S3_ENDPOINT", nil) }
33
+ # Credentials come from the usual AWS chain (AWS_ACCESS_KEY_ID…, instance roles) unless set.
34
+ setting :s3_options, default: {}
35
+ setting :url_expires_in, default: 300
36
+ setting :max_upload_size, default: 10 * 1024 * 1024
37
+ # MIME types accepted by direct_upload ("image/*" style wildcards).
38
+ # SVG and HTML are excluded on purpose: they can carry scripts.
39
+ setting :allowed_content_types, default: %w[image/png image/jpeg image/gif image/webp image/avif application/pdf]
40
+ end
41
+
42
+ class InvalidUpload < ValidationError; end
43
+
44
+ # Served inline by browsers; everything else is sent as an attachment.
45
+ INLINE_TYPES = %w[image/png image/jpeg image/gif image/webp image/avif application/pdf text/plain].freeze
46
+
47
+ class << self
48
+ def config = GemStack.config.storage
49
+
50
+ def service
51
+ @service ||= build_service(config.service)
52
+ end
53
+
54
+ attr_writer :service
55
+
56
+ def reset! = @service = nil
57
+
58
+ # Validates an upload request and returns what the browser needs:
59
+ # { key:, signed_id:, url:, method: "PUT", headers: {…} }
60
+ def direct_upload(filename:, content_type:, byte_size:, prefix: "uploads")
61
+ content_type = content_type.to_s.downcase.split(";").first.to_s.strip
62
+ byte_size = Integer(byte_size, exception: false)
63
+ validate_upload!(content_type, byte_size)
64
+
65
+ key = generate_key(prefix, filename, content_type)
66
+ upload = service.presigned_upload(key, content_type: content_type, byte_size: byte_size,
67
+ expires_in: config.url_expires_in)
68
+ { key: key, signed_id: sign(key, purpose: "upload"), **upload }
69
+ end
70
+
71
+ # The key behind a signed_id from direct_upload; 422 when tampered with
72
+ # (so clients can't point records at other people's files).
73
+ def key_for_signed_id(signed_id)
74
+ verify(signed_id, purpose: "upload") ||
75
+ raise(InvalidUpload.new("Invalid upload reference", errors: { file: ["is invalid"] }))
76
+ end
77
+
78
+ def url(key, expires_in: config.url_expires_in, disposition: nil, filename: nil)
79
+ service.url(key, expires_in: expires_in, disposition: disposition || disposition_for(key), filename: filename)
80
+ end
81
+
82
+ def upload(key, io, content_type:) = service.upload(key, io, content_type: content_type)
83
+ def download(key) = service.download(key)
84
+ def delete(key) = service.delete(key)
85
+ def exist?(key) = service.exist?(key)
86
+
87
+ def allowed_content_type?(type)
88
+ Array(config.allowed_content_types).any? do |pattern|
89
+ pattern.end_with?("/*") ? type.start_with?(pattern.delete_suffix("*")) : type == pattern
90
+ end
91
+ end
92
+
93
+ # Tamper-proof, expiring tokens (HMAC with GemStack.key_for("storage")).
94
+ def sign(value, purpose:, expires_in: nil)
95
+ payload = { "v" => value, "p" => purpose }
96
+ payload["e"] = Time.now.to_i + expires_in if expires_in
97
+ data = [JSON.generate(payload)].pack("m0").tr("+/", "-_").delete("=")
98
+ "#{data}--#{digest(data)}"
99
+ end
100
+
101
+ def verify(token, purpose:)
102
+ data, mac = token.to_s.split("--", 2)
103
+ return nil unless data && mac && OpenSSL.fixed_length_secure_compare(digest(data), mac)
104
+
105
+ payload = JSON.parse(data.tr("-_", "+/").ljust((data.length + 3) & ~3, "=").unpack1("m0"))
106
+ return nil unless payload["p"] == purpose
107
+ return nil if payload["e"] && payload["e"] < Time.now.to_i
108
+
109
+ payload["v"]
110
+ rescue ArgumentError, JSON::ParserError
111
+ nil
112
+ end
113
+
114
+ # Keys are ours, not the client's: uploads/2026/09/<uuid>/<clean name>.
115
+ # The extension always matches the validated content type, so a file
116
+ # declared as image/png can't be served as HTML because it's named x.html.
117
+ def generate_key(prefix, filename, content_type)
118
+ base = File.basename(filename.to_s, ".*").unicode_normalize(:nfkc).gsub(/[^\w-]+/, "-").gsub(/\A-+|-+\z/, "")
119
+ base = "file" if base.empty?
120
+ [prefix, Time.now.utc.strftime("%Y/%m"), SecureRandom.uuid, "#{base[0, 100]}#{extension_for(content_type)}"]
121
+ .reject(&:empty?).join("/")
122
+ end
123
+
124
+ EXTENSIONS = { "image/jpeg" => ".jpg", "image/png" => ".png", "image/gif" => ".gif", "image/webp" => ".webp",
125
+ "image/avif" => ".avif", "application/pdf" => ".pdf", "text/plain" => ".txt",
126
+ "text/csv" => ".csv" }.freeze
127
+
128
+ def extension_for(content_type) = EXTENSIONS[content_type] || Rack::Mime::MIME_TYPES.key(content_type) || ".bin"
129
+
130
+ def content_type_for(key) = Rack::Mime.mime_type(File.extname(key.to_s), "application/octet-stream")
131
+
132
+ private
133
+
134
+ def build_service(service)
135
+ case service
136
+ when :disk, "disk" then Services::Disk.new(root: config.root, path: config.path)
137
+ when :s3, "s3"
138
+ require_relative "storage/services/s3"
139
+ Services::S3.new(bucket: config.bucket, region: config.region, endpoint: config.endpoint,
140
+ **config.s3_options)
141
+ else
142
+ unless service.respond_to?(:presigned_upload)
143
+ raise ConfigurationError, "unknown config.storage.service #{service.inspect} (use :disk, :s3 or an object)"
144
+ end
145
+
146
+ service
147
+ end
148
+ end
149
+
150
+ def validate_upload!(content_type, byte_size)
151
+ errors = {}
152
+ errors[:content_type] = ["#{content_type.inspect} is not allowed"] unless allowed_content_type?(content_type)
153
+ max = config.max_upload_size
154
+ if byte_size.nil? || byte_size <= 0
155
+ errors[:byte_size] = ["must be a positive number of bytes"]
156
+ elsif byte_size > max
157
+ errors[:byte_size] = ["is too large (maximum #{max / 1024 / 1024} MB)"]
158
+ end
159
+ raise InvalidUpload.new("Upload refused", errors: errors) unless errors.empty?
160
+ end
161
+
162
+ # Browsers show images and PDFs inline; anything else downloads.
163
+ def disposition_for(key) = INLINE_TYPES.include?(content_type_for(key)) ? "inline" : "attachment"
164
+
165
+ def digest(data) = OpenSSL::HMAC.hexdigest("SHA256", GemStack.key_for("storage"), data)
166
+ end
167
+ end
168
+ end
169
+
170
+ require_relative "storage/services/disk"
171
+ require_relative "storage/endpoint"
172
+
173
+ GemStack::Config.namespace(:storage, GemStack::Storage::Config)
174
+
175
+ GemStack::Plugins.register(:storage) do |app|
176
+ next unless app.respond_to?(:config)
177
+
178
+ stack = app.config.http.middleware
179
+ # Before BodyLimit: signed uploads carry their own size limit.
180
+ unless stack.include?(GemStack::Storage::Endpoint)
181
+ stack.insert_before(GemStack::HTTP::Middleware::BodyLimit, GemStack::Storage::Endpoint)
182
+ end
183
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "date"
5
+ require "time"
6
+
7
+ module GemStack
8
+ # The type system shared by model fields, request schemas, serializers and
9
+ # the TypeScript/OpenAPI generators — one name per concept everywhere:
10
+ #
11
+ # field :price, :decimal (model)
12
+ # required :price, :decimal (request schema)
13
+ # attribute :price, :decimal (serializer)
14
+ # price: string (generated TypeScript)
15
+ #
16
+ # Each type knows how to coerce input (strings from forms and query strings,
17
+ # JSON values), how to dump values into JSON, and its TypeScript / OpenAPI
18
+ # representation. Register your own with Types.register.
19
+ module Types
20
+ # Raised by a coercer when a value can't be converted; the message is the
21
+ # client-facing validation message ("must be an integer").
22
+ class CoercionError < StandardError; end
23
+
24
+ Type = Struct.new(:name, :ts, :openapi, :coercer, :dumper, keyword_init: true) do
25
+ def coerce(value) = coercer ? coercer.call(value) : value
26
+ def dump(value) = value.nil? || dumper.nil? ? value : dumper.call(value)
27
+ end
28
+
29
+ INTEGER = /\A[-+]?\d+\z/
30
+ NUMBER = /\A[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?\z/
31
+ UUID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/i
32
+ TRUE_VALUES = [true, 1, "1", "true", "TRUE", "True", "on", "yes"].freeze
33
+ FALSE_VALUES = [false, 0, "0", "false", "FALSE", "False", "off", "no"].freeze
34
+
35
+ CLASS_ALIASES = {
36
+ String => :string, Integer => :integer, Float => :float, BigDecimal => :decimal,
37
+ TrueClass => :boolean, FalseClass => :boolean, Date => :date, Time => :datetime,
38
+ DateTime => :datetime, Hash => :json
39
+ }.freeze
40
+
41
+ @registry = {}
42
+
43
+ class << self
44
+ def register(name, ts:, openapi:, coerce: nil, dump: nil)
45
+ @registry[name.to_sym] = Type.new(name: name.to_sym, ts: ts, openapi: openapi.freeze,
46
+ coercer: coerce, dumper: dump).freeze
47
+ end
48
+
49
+ # Accepts a type name (:string) or a Ruby class (String, Integer, ...).
50
+ def fetch(name)
51
+ key = CLASS_ALIASES.fetch(name, name)
52
+ @registry.fetch(key.to_sym) do
53
+ raise ArgumentError, "unknown type #{name.inspect}; known types: #{@registry.keys.join(", ")}"
54
+ end
55
+ end
56
+
57
+ def type?(name)
58
+ CLASS_ALIASES.key?(name) || ((name.is_a?(Symbol) || name.is_a?(String)) && @registry.key?(name.to_sym))
59
+ end
60
+
61
+ def names = @registry.keys
62
+
63
+ def fail!(message) = raise(CoercionError, message)
64
+
65
+ def to_integer(value)
66
+ case value
67
+ when Integer then value
68
+ when Float then value == value.floor ? value.to_i : fail!("must be an integer")
69
+ when String then value.strip.match?(INTEGER) ? Integer(value.strip, 10) : fail!("must be an integer")
70
+ else fail!("must be an integer")
71
+ end
72
+ end
73
+
74
+ def to_float(value)
75
+ case value
76
+ when Float then value
77
+ when Numeric then value.to_f
78
+ when String then value.strip.match?(NUMBER) ? Float(value.strip) : fail!("must be a number")
79
+ else fail!("must be a number")
80
+ end
81
+ end
82
+
83
+ def to_decimal(value)
84
+ case value
85
+ when BigDecimal then value
86
+ when Integer then BigDecimal(value)
87
+ when Float then BigDecimal(value.to_s)
88
+ when String then value.strip.match?(NUMBER) ? BigDecimal(value.strip) : fail!("must be a decimal number")
89
+ else fail!("must be a decimal number")
90
+ end
91
+ end
92
+
93
+ def to_boolean(value)
94
+ return true if TRUE_VALUES.include?(value)
95
+ return false if FALSE_VALUES.include?(value)
96
+
97
+ fail!("must be true or false")
98
+ end
99
+
100
+ def to_date(value)
101
+ case value
102
+ when DateTime, Time then value.to_date
103
+ when Date then value
104
+ when String then Date.iso8601(value.strip)
105
+ else fail!("must be a date (YYYY-MM-DD)")
106
+ end
107
+ rescue ArgumentError # includes Date::Error
108
+ fail!("must be a date (YYYY-MM-DD)")
109
+ end
110
+
111
+ def to_datetime(value)
112
+ case value
113
+ when Time then value
114
+ when DateTime then value.to_time
115
+ when String then parse_time(value.strip)
116
+ else fail!("must be a date-time (ISO 8601)")
117
+ end
118
+ end
119
+
120
+ private
121
+
122
+ # ISO 8601 with a zone, or a zone-less "YYYY-MM-DDTHH:MM[:SS]" (as sent
123
+ # by <input type="datetime-local">), which is read as UTC.
124
+ def parse_time(string)
125
+ Time.iso8601(string)
126
+ rescue ArgumentError
127
+ match = string.match(/\A(\d{4})-(\d\d)-(\d\d)[T ](\d\d):(\d\d)(?::(\d\d))?\z/)
128
+ fail!("must be a date-time (ISO 8601)") unless match
129
+ Time.utc(*match.captures.compact.map(&:to_i))
130
+ end
131
+ end
132
+
133
+ string = lambda do |value|
134
+ case value
135
+ when String then value
136
+ when Symbol, Numeric then value.to_s
137
+ else fail!("must be a string")
138
+ end
139
+ end
140
+ iso_time = ->(value) { (value.respond_to?(:to_time) ? value.to_time : value).utc.iso8601(3) }
141
+
142
+ register :string, ts: "string", openapi: { type: "string" }, coerce: string
143
+ register :text, ts: "string", openapi: { type: "string" }, coerce: string
144
+ register :integer, ts: "number", openapi: { type: "integer" }, coerce: method(:to_integer)
145
+ register :bigint, ts: "number", openapi: { type: "integer", format: "int64" }, coerce: method(:to_integer)
146
+ register :references, ts: "number", openapi: { type: "integer", format: "int64" }, coerce: method(:to_integer)
147
+ register :float, ts: "number", openapi: { type: "number" }, coerce: method(:to_float)
148
+ # Decimals travel as strings so no precision is lost in JavaScript.
149
+ register :decimal, ts: "string", openapi: { type: "string", format: "decimal" },
150
+ coerce: method(:to_decimal),
151
+ dump: ->(value) { (value.is_a?(BigDecimal) ? value : BigDecimal(value.to_s)).to_s("F") }
152
+ register :boolean, ts: "boolean", openapi: { type: "boolean" }, coerce: method(:to_boolean)
153
+ register :date, ts: "string", openapi: { type: "string", format: "date" },
154
+ coerce: method(:to_date), dump: lambda(&:iso8601)
155
+ register :datetime, ts: "string", openapi: { type: "string", format: "date-time" },
156
+ coerce: method(:to_datetime), dump: iso_time
157
+ register :uuid, ts: "string", openapi: { type: "string", format: "uuid" },
158
+ coerce: lambda { |value|
159
+ value.is_a?(String) && value.match?(UUID) ? value.downcase : fail!("must be a UUID")
160
+ }
161
+ register :json, ts: "unknown", openapi: {}
162
+ end
163
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ # All GemStack gems are released together under a single version.
5
+ VERSION = "0.3.0"
6
+ end
data/lib/gemstack.rb CHANGED
@@ -33,7 +33,7 @@ module GemStack
33
33
  # class ApplicationController < GemStack::Controller
34
34
  Controller = HTTP::Controller
35
35
  Params = HTTP::Params
36
- # GemStack::Schema, GemStack::Serializer and GemStack::Types come from gemstack-schema.
36
+ # GemStack::Schema, GemStack::Serializer and GemStack::Types come from gemstack/schema.
37
37
 
38
38
  class << self
39
39
  def application
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ <% if gemstack_path -%>
6
+ # GemStack from a local checkout.
7
+ path "<%= gemstack_path %>" do
8
+ gem "gemstack"
9
+ end
10
+ <% else -%>
11
+ gem "gemstack", "~> <%= version %>" # the framework: config/app.rb chooses the modules it loads
12
+ <% end -%>
13
+ gem "puma", ">= 6.4"
14
+ <% if database? -%>
15
+ gem "<%= driver_gem %>", "<%= driver_version %>" # the <%= database_adapter %> driver (config/database.yml)
16
+ <% end -%>
17
+
18
+ # Any gem works here as usual, e.g.:
19
+ # gem "brotli" # Brotli compression (gzip is used without it)
20
+ # gem "redis-client" # shared cache: config.cache.store = :redis
21
+ # gem "stripe"
22
+
23
+ group :development, :test do
24
+ gem "irb" # `gemstack console` (a bundled gem, not a default one, in newer Rubies)
25
+ gem "minitest", "~> 5.25"
26
+ gem "rack-test", "~> 2.2"
27
+ end
@@ -0,0 +1,29 @@
1
+ # <%= module_name %>
2
+
3
+ A [GemStack](https://github.com/gemstack/gemstack) application: a Ruby API with a
4
+ Next.js + TypeScript frontend, served from one origin.
5
+
6
+ ```bash
7
+ gemstack dev # http://localhost:3000 (/api/* → Ruby, everything else → Next.js)
8
+ gemstack routes # list API routes
9
+ gemstack test # run the Ruby tests
10
+ gemstack console # IRB with the app loaded
11
+ gemstack generate controller Status show
12
+ ```
13
+
14
+ ## Layout
15
+
16
+ | Path | What lives there |
17
+ |---|---|
18
+ | `config/app.rb` | configuration (`GemStack.configure`) |
19
+ | `config/routes.rb` | API routes, relative to `/api` |
20
+ | `app/` | autoloaded code: `controllers/`, and any folder you add (`services/`, ...) |
21
+ | `test/` | Minitest + rack-test (`GemStack::TestCase`) |
22
+ | `frontend/` | the Next.js app; `lib/gemstack/client.ts` talks to the API |
23
+
24
+ ## Production
25
+
26
+ Run the API with `gemstack server` (Puma, `config/puma.rb`) and the frontend
27
+ with `npm run build && npm start` in `frontend/`. Either route `/api/*` to the
28
+ API with your reverse proxy, or set `GEMSTACK_API_URL` for the Next.js process
29
+ and it will proxy `/api/*` itself.
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared behaviour for every controller: before callbacks (e.g. authentication),
4
+ # rescue_from handlers and helpers.
5
+ class ApplicationController < GemStack::Controller
6
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The base class for this app's background jobs (docs/background-jobs.md):
4
+ # retry and discard rules declared here apply to every job.
5
+ class ApplicationJob < GemStack::Job
6
+ # retry_on Net::ReadTimeout, attempts: 5, wait: 30 # seconds, :exponential or ->(attempt) { … }
7
+ # discard_on GemStack::NotFound # e.g. the record was deleted before the job ran
8
+ # queue :default
9
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The base class for this app's mailers (docs/mail.md). Templates live in
4
+ # app/mailers/templates/<mailer>/<action>.{text,html}.erb.
5
+ class ApplicationMailer < GemStack::Mailer
6
+ # default from: "My App <hello@example.com>" # otherwise config.mail.default_from (MAIL_FROM)
7
+ # default reply_to: "support@example.com"
8
+ end
File without changes
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The base class for this app's models — add plugins, validations and helpers
4
+ # every model should share here. `class Product < ApplicationModel` maps to the
5
+ # products table (docs/models.md).
6
+ #
7
+ # Created with Class.new on purpose: `class ApplicationModel < GemStack::Model`
8
+ # would make Sequel look for an "application_models" table.
9
+ ApplicationModel = Class.new(GemStack::Model) # rubocop:disable Style/EmptyClassDefinition
10
+
11
+ class ApplicationModel
12
+ # Sequel plugins for every model, e.g.:
13
+ # plugin :dirty # previous values after an update (column_changes)
14
+
15
+ # Shared query helpers:
16
+ # dataset_module do
17
+ # def recent = order(Sequel.desc(:created_at))
18
+ # end
19
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The base class for this app's serializers (docs/serialization.md): attributes
4
+ # and helpers defined here are shared by every serializer.
5
+ class ApplicationSerializer < GemStack::Serializer
6
+ end
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Runs the GemStack CLI from this application's bundle.
5
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
6
+ require "bundler/setup"
7
+ load Gem.bin_path("gemstack-cli", "gemstack")
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "gemstack"
5
+ <% if database? -%>
6
+ require "gemstack/db" # models and migrations — the connection is in config/database.yml
7
+ <% end -%>
8
+ require "gemstack/jobs" # background jobs (app/jobs)
9
+ require "gemstack/mail" # mailers (app/mailers)
10
+ # require "gemstack/storage" # file uploads — `gemstack add storage` turns this on
11
+
12
+ # Sets the application root and loads .env files (development/test).
13
+ GemStack.setup(root: File.expand_path("..", __dir__))
14
+
15
+ # Require the gems listed in the Gemfile for the current environment.
16
+ Bundler.require(:default, GemStack.env.to_sym)
17
+
18
+ GemStack.configure do |config|
19
+ config.name = "<%= name %>"
20
+
21
+ # Settings for every environment. Everything below is optional: each line
22
+ # shows the default — uncomment one to change it. Per-environment settings go
23
+ # in config/environments/*.rb. All settings: docs/configuration.md.
24
+
25
+ # ── HTTP ────────────────────────────────────────────────────────────────
26
+ # config.http.api_path = "/api" # where the API lives (routes are relative to it)
27
+ # config.http.max_body_size = 10 * 1024 * 1024 # bytes; larger requests get 413
28
+ # config.http.json = :json # or :oj (add gem "oj"), or your own codec
29
+ # config.http.cors.origins = [] # only for frontends on another domain
30
+ # config.http.middleware.use MyMiddleware # add / insert_before / swap / delete
31
+
32
+ # ── Pagination: paginate(...) in controllers, ?page=2&per_page=50 (docs/pagination.md) ──
33
+ # config.http.pagination.per_page = 25 # records per page when the client doesn't say
34
+ # config.http.pagination.max_per_page = 100 # the most a client may ask for with ?per_page=
35
+
36
+ # ── Responses ───────────────────────────────────────────────────────────
37
+ # config.http.compression.enabled = true # gzip/Brotli responses over 1 KB (add gem "brotli" for br)
38
+ # config.http.etags = true # ETag + 304 Not Modified for GET responses
39
+
40
+ # ── Logging ─────────────────────────────────────────────────────────────
41
+ # config.logger.level = :debug # :info in production (or GEMSTACK_LOG_LEVEL)
42
+ # config.filter_parameters += %w[iban] # masked in logs, next to password, token, secret…
43
+ <% if database? -%>
44
+
45
+ # ── Database — the connection is in config/database.yml (DATABASE_URL overrides it) ──
46
+ # config.db.pool_size = 5 # default: GEMSTACK_MAX_THREADS (one per Puma thread)
47
+ # config.db.statement_timeout = 5_000 # ms
48
+ <% end -%>
49
+
50
+ # ── Cache (GemStack.cache) ──────────────────────────────────────────────
51
+ # config.cache.store = :memory # :redis to share it between processes (gem "redis-client")
52
+ # config.cache.default_expires_in = nil # seconds; nil = until evicted
53
+
54
+ # ── Background jobs ─────────────────────────────────────────────────────
55
+ # config.jobs.default_queue = "default"
56
+ # config.jobs.default_max_attempts = 10 # then the job is kept as failed
57
+
58
+ # ── Mail ────────────────────────────────────────────────────────────────
59
+ # config.mail.default_from = "<%= name %> <no-reply@localhost>" # or MAIL_FROM
60
+ end
@@ -0,0 +1,61 @@
1
+ # Database connection (docs/database.md) — the same format as Rails' database.yml.
2
+ # DATABASE_URL (TEST_DATABASE_URL in tests) overrides this file when set, which
3
+ # is what most hosting platforms provide. Adapters: sqlite3, postgresql, mysql2,
4
+ # trilogy (add the matching gem: sqlite3, pg, mysql2, trilogy).
5
+ default: &default
6
+ <% if database_adapter == "sqlite3" -%>
7
+ adapter: sqlite3
8
+ pool: <%%= ENV.fetch("GEMSTACK_MAX_THREADS", 5) %>
9
+
10
+ development:
11
+ <<: *default
12
+ database: db/development.sqlite3
13
+
14
+ test:
15
+ <<: *default
16
+ database: db/test.sqlite3
17
+
18
+ # Keep the file on persistent storage (a volume); or set DATABASE_URL=sqlite3:/data/app.sqlite3.
19
+ production:
20
+ <<: *default
21
+ database: db/production.sqlite3
22
+ <% elsif database_adapter == "postgresql" -%>
23
+ adapter: postgresql
24
+ pool: <%%= ENV.fetch("GEMSTACK_MAX_THREADS", 5) %>
25
+ # Without host/username, the local server is used as your OS user.
26
+ # host: localhost
27
+ # username: <%= database_name %>
28
+ # password: <%%= ENV["DATABASE_PASSWORD"] %>
29
+
30
+ development:
31
+ <<: *default
32
+ database: <%= database_name %>_development
33
+
34
+ test:
35
+ <<: *default
36
+ database: <%= database_name %>_test
37
+
38
+ # In production, DATABASE_URL (postgres://user:password@host:5432/name) usually replaces this.
39
+ production:
40
+ <<: *default
41
+ database: <%= database_name %>_production
42
+ <% else -%>
43
+ adapter: <%= database_adapter %>
44
+ pool: <%%= ENV.fetch("GEMSTACK_MAX_THREADS", 5) %>
45
+ host: <%%= ENV.fetch("DATABASE_HOST", "127.0.0.1") %>
46
+ username: root
47
+ password: <%%= ENV["DATABASE_PASSWORD"] %>
48
+
49
+ development:
50
+ <<: *default
51
+ database: <%= database_name %>_development
52
+
53
+ test:
54
+ <<: *default
55
+ database: <%= database_name %>_test
56
+
57
+ # In production, DATABASE_URL (<%= database_adapter %>://user:password@host:3306/name) usually replaces this.
58
+ production:
59
+ <<: *default
60
+ database: <%= database_name %>_production
61
+ <% end -%>
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Development settings: applied after config/app.rb when GEMSTACK_ENV=development
4
+ # (the default). Each line shows the default value — uncomment one to change it.
5
+ # All settings: docs/configuration.md.
6
+ GemStack.configure do |config|
7
+ # ── Code ────────────────────────────────────────────────────────────────
8
+ # config.reload_code = true # reload app/ and config/routes.rb when they change
9
+ # config.eager_load = false # load app/ lazily, for a faster boot
10
+ # config.jit = nil # :yjit or :zjit to try Ruby's JIT locally
11
+
12
+ # ── Logging ─────────────────────────────────────────────────────────────
13
+ # config.logger.level = :debug # :debug, :info, :warn, :error (or GEMSTACK_LOG_LEVEL)
14
+ # config.logger.format = :pretty # readable lines; :json to see what production logs
15
+
16
+ # ── HTTP ────────────────────────────────────────────────────────────────
17
+ # config.http.show_exceptions = true # 500s carry the exception; browsers get an error page
18
+ # config.contract.docs = true # interactive API docs at /api/docs
19
+
20
+ # ── gemstack dev ────────────────────────────────────────────────────────
21
+ # config.dev.port = 3000 # the one public port (or PORT)
22
+ # config.dev.jobs_command = %w[bundle exec gemstack jobs] # nil: don't start a jobs worker
23
+ <% if database? -%>
24
+
25
+ # ── Database (connection: config/database.yml) ──────────────────────────
26
+ # config.db.log_queries = true # every SQL statement at DEBUG
27
+ # config.db.slow_query_ms = 500 # slower queries are logged at WARN
28
+ <% end -%>
29
+
30
+ # ── Cache ───────────────────────────────────────────────────────────────
31
+ # config.cache.store = :memory # :null to switch caching off, :redis to share it
32
+
33
+ # ── Mail ────────────────────────────────────────────────────────────────
34
+ # config.mail.delivery = :log # saved to tmp/mail (.eml + .html); :smtp really sends
35
+ end