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,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Contract
5
+ # Turns the route table into a language-neutral contract (IR):
6
+ #
7
+ # {
8
+ # api_path: "/api",
9
+ # types: { "Product" => { fields: [{ name:, type:, nullable:, optional: }] }, ... },
10
+ # resources: [{ name: "products", endpoints: [{ name: "list", verb: "GET", path: "/products",
11
+ # params: [], body: nil, query: nil, response: { array: { ref: "Product" } } }] }],
12
+ # warnings: [...]
13
+ # }
14
+ #
15
+ # A type reference is { scalar: :string }, { ref: "Product" }, { array: ref },
16
+ # { object: [fields] } (anonymous nested object) or { unknown: true }.
17
+ #
18
+ # Response conventions (overridable with `returns` in the controller):
19
+ # index → [<Resource>Serializer], show/create/update → <Resource>Serializer,
20
+ # destroy → no body, anything else → unknown (with a warning).
21
+ class Builder
22
+ # Names the generated TypeScript defines itself.
23
+ RESERVED_TYPES = %w[Paginated PaginationMeta PaginationQuery RequestOptions].freeze
24
+
25
+ METHOD_NAMES = { "index" => "list", "show" => "get", "create" => "create", "update" => "update",
26
+ "destroy" => "delete" }.freeze
27
+ VERB_PREFERENCE = %w[GET POST PATCH PUT DELETE].freeze
28
+
29
+ def initialize(routes:, api_path:)
30
+ @routes = routes
31
+ @api_path = api_path.to_s
32
+ @types = {}
33
+ @warnings = []
34
+ end
35
+
36
+ def build
37
+ result = build_contract
38
+ clash = result[:types].keys & RESERVED_TYPES
39
+ unless clash.empty?
40
+ raise ConfigurationError, "API type name(s) #{clash.join(", ")} are reserved by the generated TypeScript; " \
41
+ "rename the serializer/schema or set its type_name"
42
+ end
43
+ result
44
+ end
45
+
46
+ def build_contract
47
+ resources = @routes.select(&:controller).group_by(&:controller).sort.filter_map do |controller_name, routes|
48
+ controller = resolve(controller_name) or next
49
+ endpoints = routes.group_by(&:action).map do |action, action_routes|
50
+ endpoint(controller, action, action_routes)
51
+ end
52
+ { name: controller_name, endpoints: endpoints.compact.sort_by { |e| [e[:path], e[:name]] } }
53
+ end
54
+ { api_path: @api_path, types: @types.sort.to_h, resources: resources, warnings: @warnings }
55
+ end
56
+
57
+ private
58
+
59
+ def resolve(controller_name)
60
+ const = "#{Inflector.camelize(controller_name)}Controller"
61
+ Object.const_get(const)
62
+ rescue NameError
63
+ @warnings << "#{const} is not defined; its routes are left out of the contract"
64
+ nil
65
+ end
66
+
67
+ def endpoint(controller, action, routes)
68
+ route = routes.min_by { |r| VERB_PREFERENCE.index(r.verb) || 99 }
69
+ path = route.path.delete_prefix(@api_path)
70
+ path = "/" if path.empty?
71
+ schema = controller.input_schemas[action]
72
+ input = schema && schema_ref(schema, "#{resource_name(controller)}#{Inflector.camelize(action)}Input")
73
+ get = %w[GET HEAD].include?(route.verb)
74
+ response = response_ref(controller, action)
75
+ {
76
+ name: METHOD_NAMES.fetch(action) { lower_camel(action) }, action: action, verb: route.verb, path: path,
77
+ params: route.param_names.dup, body: get ? nil : input, query: get ? input : nil,
78
+ response: response, paginated: response.is_a?(Hash) && response.key?(:page)
79
+ }
80
+ end
81
+
82
+ def response_ref(controller, action)
83
+ if controller.response_types.key?(action)
84
+ type = controller.response_types[action]
85
+ return type.nil? ? nil : type_ref(type)
86
+ end
87
+
88
+ serializer = conventional_serializer(controller)
89
+ case action
90
+ when "index" then serializer ? { array: serializer_ref(serializer) } : unknown(controller, action)
91
+ when "show", "create", "update" then serializer ? serializer_ref(serializer) : unknown(controller, action)
92
+ when "destroy" then nil
93
+ else unknown(controller, action)
94
+ end
95
+ end
96
+
97
+ def unknown(controller, action)
98
+ @warnings << "#{controller.name}##{action}: response type unknown (add `returns :#{action}, SomeSerializer`)"
99
+ { unknown: true }
100
+ end
101
+
102
+ # ProductsController → ProductSerializer (Admin::ProductsController tries Admin::ProductSerializer first).
103
+ def conventional_serializer(controller)
104
+ parts = controller.name.delete_suffix("Controller").split("::")
105
+ singular = Inflector.singularize(parts.last)
106
+ candidates = ["#{(parts[0...-1] + [singular]).join("::")}Serializer", "#{singular}Serializer"].uniq
107
+ candidates.each do |name|
108
+ klass = Object.const_get(name) if Object.const_defined?(name)
109
+ return klass if klass.is_a?(Class) && klass <= Serializer
110
+ rescue NameError
111
+ next
112
+ end
113
+ nil
114
+ end
115
+
116
+ def resource_name(controller)
117
+ Inflector.singularize(controller.name.delete_suffix("Controller").split("::").join)
118
+ end
119
+
120
+ def type_ref(type)
121
+ case type
122
+ when HTTP::Page::Type then { page: type_ref(type.item) }
123
+ when Array then { array: type_ref(type.first) }
124
+ when Class
125
+ return serializer_ref(type) if type <= Serializer
126
+ return schema_ref(type, type.name.to_s.split("::").join) if type <= Schema
127
+
128
+ { scalar: Types.fetch(type).name }
129
+ else { scalar: Types.fetch(type).name }
130
+ end
131
+ end
132
+
133
+ def serializer_ref(serializer)
134
+ name = serializer.type_name
135
+ unless @types.key?(name)
136
+ @types[name] = :pending # guards against recursive serializers
137
+ fields = serializer.resolved_attributes.map do |attr|
138
+ if attr[:type] == :json && !serializer.attributes_list[attr[:name]].type
139
+ @warnings << "#{serializer.name}##{attr[:name]}: type unknown, emitted as unknown"
140
+ end
141
+ { name: attr[:name].to_s, type: type_ref(attr[:type]), nullable: attr[:nullable], optional: false }
142
+ end
143
+ @types[name] = { fields: fields }
144
+ end
145
+ { ref: name }
146
+ end
147
+
148
+ def schema_ref(schema, fallback_name)
149
+ name = schema.type_name || fallback_name
150
+ @types[name] ||= { fields: schema_fields(schema) }
151
+ { ref: name }
152
+ end
153
+
154
+ def schema_fields(schema)
155
+ schema.fields.values.map do |field|
156
+ type = field.schema ? { object: schema_fields(field.schema) } : { scalar: field.type }
157
+ type = { array: type } if field.array
158
+ { name: field.name.to_s, type: type, nullable: field.nullable, optional: field.ts_optional? }
159
+ end
160
+ end
161
+
162
+ def lower_camel(name)
163
+ camel = Inflector.camelize(name)
164
+ camel[0].downcase + camel[1..]
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,264 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>__TITLE__ API · GemStack</title>
7
+ <style>
8
+ :root { color-scheme: light dark; --bg: #fff; --fg: #16181d; --muted: #5b6170; --panel: #f4f5f8; --line: #e3e5ea;
9
+ --get: #1f7a4d; --post: #2b59c3; --patch: #9a6700; --put: #7a3fb0; --delete: #c53030; --focus: #2b59c3; }
10
+ @media (prefers-color-scheme: dark) { :root { --bg: #0f1115; --fg: #e6e8ee; --muted: #9aa0ad; --panel: #181c26;
11
+ --line: #262b38; --get: #4cc38a; --post: #7ea2ff; --patch: #e0b44c; --put: #c49bf0; --delete: #ff7b7b; --focus: #7ea2ff; } }
12
+ * { box-sizing: border-box; }
13
+ body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
14
+ header { position: sticky; top: 0; z-index: 2; display: flex; gap: 1rem; align-items: center; padding: .7rem 1rem;
15
+ background: var(--bg); border-bottom: 1px solid var(--line); flex-wrap: wrap; }
16
+ header h1 { font-size: 1.05rem; margin: 0; } header .muted { font-size: .85rem; }
17
+ header input { flex: 1; min-width: 10rem; max-width: 24rem; }
18
+ .layout { display: grid; grid-template-columns: 15rem minmax(0, 1fr); }
19
+ nav { position: sticky; top: 3.3rem; align-self: start; max-height: calc(100vh - 3.3rem); overflow-y: auto; padding: 1rem;
20
+ border-right: 1px solid var(--line); font-size: .9rem; }
21
+ nav h2 { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin: 1rem 0 .3rem; }
22
+ nav a { display: flex; gap: .4rem; padding: .15rem 0; color: inherit; text-decoration: none; overflow-wrap: anywhere; }
23
+ nav a:hover { text-decoration: underline; }
24
+ main { padding: 1rem 1.5rem 4rem; min-width: 0; }
25
+ section.op { border: 1px solid var(--line); border-radius: 10px; margin: 0 0 1rem; overflow: hidden; }
26
+ section.op > summary { list-style: none; } details > summary::-webkit-details-marker { display: none; }
27
+ summary { display: flex; gap: .7rem; align-items: center; padding: .6rem .9rem; cursor: pointer; flex-wrap: wrap; }
28
+ summary code { font-size: .95rem; overflow-wrap: anywhere; }
29
+ .body { padding: .2rem 1rem 1rem; border-top: 1px solid var(--line); }
30
+ .verb { font: 700 .72rem/1 ui-monospace, Menlo, monospace; padding: .3rem .45rem; border-radius: 5px; color: #fff; min-width: 3.9rem; text-align: center; }
31
+ .GET { background: var(--get); } .POST { background: var(--post); } .PATCH { background: var(--patch); }
32
+ .PUT { background: var(--put); } .DELETE { background: var(--delete); }
33
+ nav .verb { min-width: 3.2rem; font-size: .62rem; padding: .22rem .3rem; }
34
+ h3 { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin: 1rem 0 .35rem; }
35
+ pre, textarea, input, select, button { font: 13px/1.5 ui-monospace, Menlo, monospace; }
36
+ pre { background: var(--panel); border-radius: 8px; padding: .7rem .9rem; margin: 0; overflow-x: auto; white-space: pre; }
37
+ table { border-collapse: collapse; width: 100%; font-size: .9rem; } td, th { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid var(--line); vertical-align: top; }
38
+ th { color: var(--muted); font-weight: 600; }
39
+ input, textarea, select { width: 100%; padding: .4rem .5rem; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--fg); }
40
+ input:focus, textarea:focus, button:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
41
+ textarea { min-height: 7rem; resize: vertical; }
42
+ button { padding: .45rem .9rem; border-radius: 6px; border: 1px solid var(--fg); background: var(--fg); color: var(--bg); cursor: pointer; }
43
+ button:disabled { opacity: .6; cursor: default; }
44
+ .row { display: flex; gap: .6rem; align-items: center; margin-top: .6rem; flex-wrap: wrap; }
45
+ .muted { color: var(--muted); } .warn { border-left: 3px solid var(--patch); padding: .4rem .8rem; background: var(--panel); border-radius: 4px; margin: 0 0 1rem; font-size: .9rem; }
46
+ a.ref { color: var(--focus); }
47
+ .status { font-weight: 700; } .ok { color: var(--get); } .bad { color: var(--delete); }
48
+ @media (max-width: 760px) { .layout { grid-template-columns: 1fr; } nav { position: static; max-height: none; border-right: 0; border-bottom: 1px solid var(--line); } main { padding: 1rem; } }
49
+ </style>
50
+ </head>
51
+ <body>
52
+ <header>
53
+ <h1>__TITLE__ API</h1>
54
+ <input id="filter" type="search" placeholder="Filter endpoints…" aria-label="Filter endpoints">
55
+ <span class="muted">Development only · <a class="ref" href="__OPENAPI_URL__">openapi.json</a></span>
56
+ </header>
57
+ <div class="layout">
58
+ <nav id="nav" aria-label="Endpoints"></nav>
59
+ <main id="main"><p class="muted">Loading…</p></main>
60
+ </div>
61
+ <script>
62
+ "use strict";
63
+ const OPENAPI_URL = "__OPENAPI_URL__";
64
+ const VERBS = ["get", "post", "put", "patch", "delete"];
65
+ let doc;
66
+
67
+ function el(tag, attrs, ...children) {
68
+ const node = document.createElement(tag);
69
+ for (const [key, value] of Object.entries(attrs || {})) {
70
+ if (value === undefined || value === null || value === false) continue;
71
+ if (key === "class") node.className = value;
72
+ else if (key.startsWith("on")) node.addEventListener(key.slice(2), value);
73
+ else node.setAttribute(key, value === true ? "" : value);
74
+ }
75
+ for (const child of children.flat(Infinity)) {
76
+ if (child === null || child === undefined || child === false) continue;
77
+ node.append(child instanceof Node ? child : String(child));
78
+ }
79
+ return node;
80
+ }
81
+
82
+ const refName = (ref) => ref.split("/").pop();
83
+ const resolve = (schema) => (schema && schema.$ref ? doc.components.schemas[refName(schema.$ref)] : schema);
84
+
85
+ // JSON Schema → a TypeScript-like description (matches the generated client types).
86
+ function typeText(schema, indent = "") {
87
+ if (!schema) return "unknown";
88
+ if (schema.$ref) return refName(schema.$ref);
89
+ const variants = schema.anyOf || schema.oneOf;
90
+ if (variants) return variants.map((s) => typeText(s, indent)).join(" | ");
91
+ if (schema.enum) return schema.enum.map((v) => JSON.stringify(v)).join(" | ");
92
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
93
+ const nullable = types.includes("null");
94
+ const type = types.find((t) => t !== "null");
95
+ let text;
96
+ if (type === "array") text = `${wrap(typeText(schema.items, indent))}[]`;
97
+ else if (type === "object" && schema.properties) {
98
+ const required = new Set(schema.required || []);
99
+ const inner = indent + " ";
100
+ const lines = Object.entries(schema.properties).map(
101
+ ([name, prop]) => `${inner}${name}${required.has(name) ? "" : "?"}: ${typeText(prop, inner)};`,
102
+ );
103
+ text = `{\n${lines.join("\n")}\n${indent}}`;
104
+ } else if (type === "object") text = schema.additionalProperties ? `Record<string, ${typeText(schema.additionalProperties, indent)}>` : "Record<string, unknown>";
105
+ else if (type === "integer" || type === "number") text = "number";
106
+ else if (type === "string") text = schema.format ? `string /* ${schema.format} */` : "string";
107
+ else text = type || "unknown";
108
+ return nullable ? `${text} | null` : text;
109
+ }
110
+ const wrap = (t) => (t.includes("|") ? `(${t})` : t);
111
+
112
+ function example(schema, depth = 0) {
113
+ schema = resolve(schema);
114
+ if (!schema || depth > 4) return null;
115
+ if (schema.example !== undefined) return schema.example;
116
+ if (schema.enum) return schema.enum[0];
117
+ if (schema.anyOf || schema.oneOf) return example((schema.anyOf || schema.oneOf)[0], depth + 1);
118
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
119
+ const type = types.find((t) => t !== "null");
120
+ if (type === "object") {
121
+ const out = {};
122
+ for (const [name, prop] of Object.entries(schema.properties || {})) out[name] = example(prop, depth + 1);
123
+ return out;
124
+ }
125
+ if (type === "array") return [example(schema.items, depth + 1)];
126
+ if (type === "integer" || type === "number") return 0;
127
+ if (type === "boolean") return false;
128
+ if (type === "string") return { "date-time": new Date().toISOString(), date: new Date().toISOString().slice(0, 10), uuid: crypto.randomUUID() }[schema.format] || "";
129
+ return null;
130
+ }
131
+
132
+ // Types mentioned by a schema, linked below the signature.
133
+ function refsIn(schema, found = new Set()) {
134
+ if (!schema || typeof schema !== "object") return found;
135
+ if (schema.$ref) {
136
+ const name = refName(schema.$ref);
137
+ if (!found.has(name)) { found.add(name); refsIn(doc.components.schemas[name], found); }
138
+ return found;
139
+ }
140
+ for (const value of Object.values(schema)) if (typeof value === "object") refsIn(value, found);
141
+ return found;
142
+ }
143
+
144
+ function schemaBlock(schema) {
145
+ const refs = [...refsIn(schema)].filter((name) => name !== "Error");
146
+ return [
147
+ el("pre", {}, typeText(schema)),
148
+ refs.length ? el("p", { class: "muted" }, "Types: ", refs.flatMap((name, i) => [i ? ", " : "", el("a", { class: "ref", href: `#type-${name}` }, name)])) : null,
149
+ ];
150
+ }
151
+
152
+ function operationSection(path, verb, op) {
153
+ const method = verb.toUpperCase();
154
+ const id = `op-${op.operationId || method + path}`.replace(/[^\w.-]/g, "-");
155
+ const params = op.parameters || [];
156
+ const bodySchema = op.requestBody?.content?.["application/json"]?.schema;
157
+ const [status, response] = Object.entries(op.responses || {}).find(([code]) => code !== "default") || [];
158
+ const responseSchema = response?.content?.["application/json"]?.schema;
159
+
160
+ const inputs = {};
161
+ const paramRows = params.map((p) => {
162
+ inputs[p.name] = el("input", { name: p.name, placeholder: p.required ? "required" : "optional", "aria-label": p.name });
163
+ return el("tr", {}, el("td", {}, el("code", {}, p.name)), el("td", {}, p.in), el("td", {}, typeText(p.schema)), el("td", {}, inputs[p.name]));
164
+ });
165
+ const bodyInput = bodySchema ? el("textarea", { "aria-label": "Request body (JSON)", spellcheck: "false" }, JSON.stringify(example(bodySchema), null, 2)) : null;
166
+ const output = el("div");
167
+ const send = el("button", { type: "button" }, "Send request");
168
+ send.addEventListener("click", async () => {
169
+ send.disabled = true;
170
+ output.replaceChildren(el("p", { class: "muted" }, "Sending…"));
171
+ try {
172
+ let url = path;
173
+ const query = new URLSearchParams();
174
+ for (const p of params) {
175
+ const value = inputs[p.name].value;
176
+ if (p.in === "path") url = url.replace(`{${p.name}}`, encodeURIComponent(value));
177
+ else if (p.in === "query" && value !== "") query.append(p.name, value);
178
+ }
179
+ if (query.toString()) url += `?${query}`;
180
+ const init = { method, headers: { accept: "application/json" }, credentials: "same-origin" };
181
+ if (bodyInput) {
182
+ JSON.parse(bodyInput.value || "null"); // fail early on invalid JSON
183
+ init.body = bodyInput.value;
184
+ init.headers["content-type"] = "application/json";
185
+ }
186
+ const started = performance.now();
187
+ const res = await fetch(url, init);
188
+ const ms = Math.round(performance.now() - started);
189
+ const text = await res.text();
190
+ let shown = text;
191
+ try { shown = JSON.stringify(JSON.parse(text), null, 2); } catch { /* not JSON */ }
192
+ output.replaceChildren(
193
+ el("p", {}, el("span", { class: `status ${res.ok ? "ok" : "bad"}` }, `${res.status} ${res.statusText}`), el("span", { class: "muted" }, ` · ${ms} ms · ${method} ${url}`)),
194
+ text ? el("pre", {}, shown) : el("p", { class: "muted" }, "(no body)"),
195
+ );
196
+ } catch (error) {
197
+ output.replaceChildren(el("p", { class: "bad" }, String(error.message || error)));
198
+ } finally {
199
+ send.disabled = false;
200
+ }
201
+ });
202
+
203
+ return el("details", { class: "op", id, "data-search": `${method} ${path} ${op.operationId || ""}`.toLowerCase() },
204
+ el("summary", {}, el("span", { class: `verb ${method}` }, method), el("code", {}, path), el("span", { class: "muted" }, op.operationId || "")),
205
+ el("div", { class: "body" },
206
+ params.length ? [el("h3", {}, "Parameters"), el("table", {}, el("tr", {}, el("th", {}, "Name"), el("th", {}, "In"), el("th", {}, "Type"), el("th", {}, "Value")), paramRows)] : null,
207
+ bodySchema ? [el("h3", {}, "Request body"), schemaBlock(bodySchema)] : null,
208
+ el("h3", {}, `Response ${status || ""}`), responseSchema ? schemaBlock(responseSchema) : el("p", { class: "muted" }, "No body"),
209
+ el("h3", {}, "Try it"),
210
+ el("p", { class: "muted" }, "Sent from this page with your session cookie (sign in through the app first for protected endpoints)."),
211
+ bodyInput, el("div", { class: "row" }, send), output,
212
+ ),
213
+ );
214
+ }
215
+
216
+ function render() {
217
+ const main = document.getElementById("main");
218
+ const nav = document.getElementById("nav");
219
+ const groups = new Map();
220
+ for (const [path, item] of Object.entries(doc.paths || {})) {
221
+ for (const verb of VERBS) {
222
+ const op = item[verb];
223
+ if (!op) continue;
224
+ const tag = (op.tags && op.tags[0]) || "other";
225
+ if (!groups.has(tag)) groups.set(tag, []);
226
+ groups.get(tag).push({ path, verb, op });
227
+ }
228
+ }
229
+ const content = [];
230
+ const warnings = doc["x-gemstack-warnings"] || [];
231
+ if (warnings.length) content.push(el("div", { class: "warn" }, el("strong", {}, "Contract warnings"), el("ul", {}, warnings.map((w) => el("li", {}, w)))));
232
+ const navItems = [];
233
+ for (const [tag, ops] of [...groups.entries()].sort()) {
234
+ content.push(el("h2", { id: `tag-${tag}` }, tag));
235
+ navItems.push(el("h2", {}, tag));
236
+ for (const { path, verb, op } of ops) {
237
+ const section = operationSection(path, verb, op);
238
+ content.push(section);
239
+ navItems.push(el("a", { href: `#${section.id}`, "data-search": section.dataset.search, onclick: () => (section.open = true) },
240
+ el("span", { class: `verb ${verb.toUpperCase()}` }, verb.toUpperCase()), path));
241
+ }
242
+ }
243
+ const schemas = Object.entries(doc.components?.schemas || {}).sort(([a], [b]) => a.localeCompare(b));
244
+ content.push(el("h2", { id: "types" }, "Types"));
245
+ for (const [name, schema] of schemas) content.push(el("div", { id: `type-${name}` }, el("h3", {}, name), el("pre", {}, typeText(schema))));
246
+ if (!groups.size) content.unshift(el("p", { class: "muted" }, "No routes yet. Add some in config/routes.rb — this page rebuilds on every load."));
247
+ main.replaceChildren(...content);
248
+ nav.replaceChildren(...navItems, el("h2", {}, el("a", { href: "#types" }, "Types")));
249
+ const hash = decodeURIComponent(location.hash.slice(1));
250
+ if (hash) { const target = document.getElementById(hash); if (target) { if (target.tagName === "DETAILS") target.open = true; target.scrollIntoView(); } }
251
+ }
252
+
253
+ document.getElementById("filter").addEventListener("input", (event) => {
254
+ const q = event.target.value.trim().toLowerCase();
255
+ for (const node of document.querySelectorAll("[data-search]")) node.hidden = q !== "" && !node.dataset.search.includes(q);
256
+ });
257
+
258
+ fetch(OPENAPI_URL, { headers: { accept: "application/json" } })
259
+ .then((res) => (res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status} loading ${OPENAPI_URL}`))))
260
+ .then((json) => { doc = json; render(); })
261
+ .catch((error) => document.getElementById("main").replaceChildren(el("p", { class: "bad" }, String(error.message || error))));
262
+ </script>
263
+ </body>
264
+ </html>
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Contract
5
+ # Development API docs:
6
+ # GET <api_path>/docs interactive page (self-contained, no CDN)
7
+ # GET <api_path>/docs/openapi.json OpenAPI 3.1 built from the current routes
8
+ # Rebuilt on every request, so it always matches the code after a reload.
9
+ class Docs
10
+ PAGE = File.read(File.join(__dir__, "docs", "index.html")).freeze
11
+ CSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; " \
12
+ "img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
13
+
14
+ def initialize(app, application)
15
+ @app = app
16
+ @application = application
17
+ end
18
+
19
+ def call(env)
20
+ path = env[Rack::PATH_INFO]
21
+ base = "#{@application.config.http.api_path}/docs"
22
+ return @app.call(env) unless env[Rack::REQUEST_METHOD] == "GET" && [base, "#{base}/",
23
+ "#{base}/openapi.json"].include?(path)
24
+
25
+ path.end_with?(".json") ? openapi : page(base)
26
+ end
27
+
28
+ private
29
+
30
+ def openapi
31
+ contract = Builder.new(routes: @application.routes, api_path: @application.config.http.api_path).build
32
+ body = JSON.generate(OpenAPI.new(contract).document.merge("x-gemstack-warnings" => contract[:warnings]))
33
+ [200, { "content-type" => "application/json; charset=utf-8", "cache-control" => "no-store" }, [body]]
34
+ end
35
+
36
+ def page(base)
37
+ title = Rack::Utils.escape_html(GemStack.config.name.to_s)
38
+ html = PAGE.gsub("__OPENAPI_URL__", "#{base}/openapi.json").gsub("__TITLE__", title)
39
+ headers = { "content-type" => "text/html; charset=utf-8", "cache-control" => "no-store",
40
+ "content-security-policy" => CSP }
41
+ [200, headers, [html]]
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Contract
5
+ # OpenAPI 3.1 document from the contract IR.
6
+ class OpenAPI
7
+ ERROR_SCHEMA = {
8
+ type: "object",
9
+ required: ["error"],
10
+ properties: {
11
+ error: {
12
+ type: "object", required: %w[code message],
13
+ properties: { code: { type: "string" }, message: { type: "string" }, request_id: { type: "string" } }
14
+ },
15
+ errors: { type: "object", additionalProperties: { type: "array", items: { type: "string" } } }
16
+ }
17
+ }.freeze
18
+
19
+ def initialize(contract, title: GemStack.config.name, version: GemStack::VERSION)
20
+ @contract = contract
21
+ @title = title
22
+ @version = version
23
+ end
24
+
25
+ def document
26
+ {
27
+ openapi: "3.1.0",
28
+ info: { title: @title, version: @version },
29
+ paths: paths,
30
+ components: { schemas: schemas.merge("Error" => ERROR_SCHEMA) }
31
+ }
32
+ end
33
+
34
+ PAGE_PARAMETERS = %w[page per_page].map do |name|
35
+ { name: name, in: "query", required: false, schema: { type: "integer", minimum: 1 } }
36
+ end.freeze
37
+
38
+ private
39
+
40
+ def paths
41
+ @contract[:resources].flat_map { |resource| resource[:endpoints].map { |e| [resource, e] } }
42
+ .group_by { |_, e| openapi_path(e[:path]) }
43
+ .transform_values do |pairs|
44
+ pairs.to_h do |resource, e|
45
+ [e[:verb].downcase, operation(resource, e)]
46
+ end
47
+ end
48
+ end
49
+
50
+ def operation(resource, endpoint)
51
+ op = {
52
+ operationId: "#{resource[:name].tr("/", "_")}.#{endpoint[:name]}",
53
+ tags: [resource[:name]],
54
+ parameters: endpoint[:params].map { |p| { name: p, in: "path", required: true, schema: { type: "string" } } },
55
+ responses: responses(endpoint)
56
+ }
57
+ op[:parameters].concat(query_parameters(endpoint[:query])) if endpoint[:query]
58
+ op[:parameters].concat(PAGE_PARAMETERS) if endpoint[:paginated]
59
+ if endpoint[:body]
60
+ op[:requestBody] =
61
+ { required: true, content: { "application/json" => { schema: schema_for(endpoint[:body]) } } }
62
+ end
63
+ op
64
+ end
65
+
66
+ def responses(endpoint)
67
+ success = endpoint[:verb] == "POST" ? "201" : "200"
68
+ ok = if endpoint[:response]
69
+ { success => { description: "OK",
70
+ content: { "application/json" => { schema: schema_for(endpoint[:response]) } } } }
71
+ else
72
+ { "204" => { description: "No Content" } }
73
+ end
74
+ error = { description: "Error",
75
+ content: { "application/json" => { schema: { "$ref": "#/components/schemas/Error" } } } }
76
+ ok.merge("default" => error)
77
+ end
78
+
79
+ def query_parameters(ref)
80
+ fields = ref[:ref] ? @contract[:types].dig(ref[:ref], :fields) : []
81
+ Array(fields).map do |field|
82
+ { name: field[:name], in: "query", required: !field[:optional], schema: schema_for(field[:type]) }
83
+ end
84
+ end
85
+
86
+ def schemas
87
+ @contract[:types].transform_values { |type| object_schema(type[:fields]) }
88
+ end
89
+
90
+ def object_schema(fields)
91
+ properties = fields.to_h do |field|
92
+ schema = schema_for(field[:type])
93
+ schema = { oneOf: [schema, { type: "null" }] } if field[:nullable]
94
+ [field[:name], schema]
95
+ end
96
+ { type: "object", properties: properties, required: fields.reject { |f| f[:optional] }.map { |f| f[:name] } }
97
+ end
98
+
99
+ def schema_for(ref)
100
+ if ref[:scalar] then Types.fetch(ref[:scalar]).openapi.dup
101
+ elsif ref[:ref] then { "$ref": "#/components/schemas/#{ref[:ref]}" }
102
+ elsif ref[:array] then { type: "array", items: schema_for(ref[:array]) }
103
+ elsif ref[:page] then page_schema(ref[:page])
104
+ elsif ref[:object] then object_schema(ref[:object])
105
+ else {}
106
+ end
107
+ end
108
+
109
+ def page_schema(item)
110
+ meta = %w[page per_page total total_pages].to_h { |key| [key, { type: "integer" }] }
111
+ {
112
+ type: "object", required: %w[data meta],
113
+ properties: {
114
+ data: { type: "array", items: schema_for(item) },
115
+ meta: { type: "object", required: meta.keys, properties: meta }
116
+ }
117
+ }
118
+ end
119
+
120
+ def openapi_path(path) = "#{@contract[:api_path]}#{path.gsub(/[:*](\w+)/, '{\1}')}"
121
+ end
122
+ end
123
+ end