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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +7 -3
- data/lib/gemstack/cache/memory_store.rb +69 -0
- data/lib/gemstack/cache/null_store.rb +18 -0
- data/lib/gemstack/cache/redis_store.rb +70 -0
- data/lib/gemstack/cache/store.rb +89 -0
- data/lib/gemstack/cache.rb +61 -0
- data/lib/gemstack/cli/add_generator.rb +258 -0
- data/lib/gemstack/cli/app_generator.rb +142 -0
- data/lib/gemstack/cli/commands/db.rb +136 -0
- data/lib/gemstack/cli/commands/jobs.rb +96 -0
- data/lib/gemstack/cli/controller_generator.rb +73 -0
- data/lib/gemstack/cli/deploy_generator.rb +102 -0
- data/lib/gemstack/cli/doctor/upgrade_check.rb +32 -0
- data/lib/gemstack/cli/doctor.rb +311 -0
- data/lib/gemstack/cli/generator.rb +161 -0
- data/lib/gemstack/cli/job_generator.rb +48 -0
- data/lib/gemstack/cli/migration_generator.rb +52 -0
- data/lib/gemstack/cli/policy_generator.rb +35 -0
- data/lib/gemstack/cli/project.rb +53 -0
- data/lib/gemstack/cli/resource_generator.rb +170 -0
- data/lib/gemstack/cli/resource_spec.rb +95 -0
- data/lib/gemstack/cli.rb +304 -0
- data/lib/gemstack/contract/builder.rb +168 -0
- data/lib/gemstack/contract/docs/index.html +264 -0
- data/lib/gemstack/contract/docs.rb +45 -0
- data/lib/gemstack/contract/openapi.rb +123 -0
- data/lib/gemstack/contract/typescript.rb +162 -0
- data/lib/gemstack/contract.rb +93 -0
- data/lib/gemstack/core.rb +129 -0
- data/lib/gemstack/db/configuration.rb +148 -0
- data/lib/gemstack/db/errors.rb +137 -0
- data/lib/gemstack/db/json_compat.rb +21 -0
- data/lib/gemstack/db/migrator.rb +80 -0
- data/lib/gemstack/db/model.rb +226 -0
- data/lib/gemstack/db/schema_types.rb +28 -0
- data/lib/gemstack/db/tasks.rb +94 -0
- data/lib/gemstack/db/testing.rb +99 -0
- data/lib/gemstack/db.rb +210 -0
- data/lib/gemstack/dev/file_watcher.rb +58 -0
- data/lib/gemstack/dev/gateway.rb +220 -0
- data/lib/gemstack/dev/managed_process.rb +96 -0
- data/lib/gemstack/dev/ports.rb +26 -0
- data/lib/gemstack/dev/supervisor.rb +267 -0
- data/lib/gemstack/dev/terminal.rb +40 -0
- data/lib/gemstack/dev/toolchain.rb +86 -0
- data/lib/gemstack/dev.rb +43 -0
- data/lib/gemstack/dotenv.rb +61 -0
- data/lib/gemstack/environment.rb +37 -0
- data/lib/gemstack/error_mapping.rb +37 -0
- data/lib/gemstack/errors.rb +100 -0
- data/lib/gemstack/http/app.rb +30 -0
- data/lib/gemstack/http/config.rb +84 -0
- data/lib/gemstack/http/controller.rb +343 -0
- data/lib/gemstack/http/error_page.rb +111 -0
- data/lib/gemstack/http/error_renderer.rb +63 -0
- data/lib/gemstack/http/json_codec.rb +114 -0
- data/lib/gemstack/http/middleware/body_limit.rb +69 -0
- data/lib/gemstack/http/middleware/compression.rb +127 -0
- data/lib/gemstack/http/middleware/cors.rb +77 -0
- data/lib/gemstack/http/middleware/error_handler.rb +39 -0
- data/lib/gemstack/http/middleware/etags.rb +24 -0
- data/lib/gemstack/http/middleware/health_check.rb +28 -0
- data/lib/gemstack/http/middleware/request_id.rb +31 -0
- data/lib/gemstack/http/middleware/request_logger.rb +39 -0
- data/lib/gemstack/http/middleware/security_headers.rb +31 -0
- data/lib/gemstack/http/middleware_stack.rb +96 -0
- data/lib/gemstack/http/page.rb +36 -0
- data/lib/gemstack/http/params.rb +140 -0
- data/lib/gemstack/http/request.rb +64 -0
- data/lib/gemstack/http/router.rb +315 -0
- data/lib/gemstack/http.rb +41 -0
- data/lib/gemstack/inflector.rb +133 -0
- data/lib/gemstack/job.rb +154 -0
- data/lib/gemstack/jobs/adapters/async.rb +94 -0
- data/lib/gemstack/jobs/adapters/database.rb +188 -0
- data/lib/gemstack/jobs/adapters/inline.rb +34 -0
- data/lib/gemstack/jobs/adapters/sidekiq.rb +65 -0
- data/lib/gemstack/jobs/adapters/test.rb +59 -0
- data/lib/gemstack/jobs/executor.rb +70 -0
- data/lib/gemstack/jobs/testing.rb +55 -0
- data/lib/gemstack/jobs/worker.rb +137 -0
- data/lib/gemstack/jobs.rb +144 -0
- data/lib/gemstack/logger.rb +131 -0
- data/lib/gemstack/mail/delivery_job.rb +20 -0
- data/lib/gemstack/mail/testing.rb +33 -0
- data/lib/gemstack/mail.rb +230 -0
- data/lib/gemstack/plugins.rb +38 -0
- data/lib/gemstack/schema.rb +251 -0
- data/lib/gemstack/serializer.rb +186 -0
- data/lib/gemstack/settings.rb +86 -0
- data/lib/gemstack/storage/endpoint.rb +112 -0
- data/lib/gemstack/storage/services/disk.rb +60 -0
- data/lib/gemstack/storage/services/s3.rb +65 -0
- data/lib/gemstack/storage/testing.rb +30 -0
- data/lib/gemstack/storage.rb +183 -0
- data/lib/gemstack/types.rb +163 -0
- data/lib/gemstack/version.rb +6 -0
- data/lib/gemstack.rb +1 -1
- data/templates/app/Gemfile.tt +27 -0
- data/templates/app/README.md.tt +29 -0
- data/templates/app/app/controllers/application_controller.rb +6 -0
- data/templates/app/app/jobs/application_job.rb +9 -0
- data/templates/app/app/mailers/application_mailer.rb +8 -0
- data/templates/app/app/mailers/templates/dot_keep +0 -0
- data/templates/app/app/models/application_model.rb +19 -0
- data/templates/app/app/serializers/application_serializer.rb +6 -0
- data/templates/app/bin/gemstack +7 -0
- data/templates/app/config/app.rb.tt +60 -0
- data/templates/app/config/database.yml.tt +61 -0
- data/templates/app/config/environments/development.rb.tt +35 -0
- data/templates/app/config/environments/production.rb.tt +40 -0
- data/templates/app/config/environments/test.rb.tt +21 -0
- data/templates/app/config/puma.rb +21 -0
- data/templates/app/config/routes.rb +10 -0
- data/templates/app/config.ru +6 -0
- data/templates/app/db/migrations/dot_keep +0 -0
- data/templates/app/db/seeds.rb +5 -0
- data/templates/app/dot_env.example.tt +19 -0
- data/templates/app/dot_gitignore +16 -0
- data/templates/app/dot_node-version.tt +1 -0
- data/templates/app/dot_nvmrc.tt +1 -0
- data/templates/app/dot_ruby-version.tt +1 -0
- data/templates/app/dot_tool-versions.tt +4 -0
- data/templates/app/test/health_test.rb +18 -0
- data/templates/app/test/test_helper.rb.tt +25 -0
- data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
- data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
- data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
- data/templates/auth/app/controllers/registrations_controller.rb +19 -0
- data/templates/auth/app/controllers/sessions_controller.rb +29 -0
- data/templates/auth/app/mailers/auth_mailer.rb +20 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
- data/templates/auth/app/models/auth_token.rb +15 -0
- data/templates/auth/app/models/user.rb +12 -0
- data/templates/auth/app/policies/application_policy.rb +6 -0
- data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
- data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
- data/templates/auth/app/serializers/user_serializer.rb +6 -0
- data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
- data/templates/auth/frontend/app/account/page.tsx +116 -0
- data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
- data/templates/auth/frontend/app/login/page.tsx +26 -0
- data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
- data/templates/auth/frontend/app/signup/page.tsx +27 -0
- data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
- data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
- data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
- data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
- data/templates/auth/frontend/lib/auth.ts +113 -0
- data/templates/auth/test/controllers/auth_test.rb +108 -0
- data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
- data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
- data/templates/deploy/Caddyfile.tt +19 -0
- data/templates/deploy/Dockerfile.tt +63 -0
- data/templates/deploy/Procfile.tt +6 -0
- data/templates/deploy/compose.yaml.tt +129 -0
- data/templates/deploy/dot_dockerignore +14 -0
- data/templates/frontend/app/globals.css +171 -0
- data/templates/frontend/app/layout.tsx.tt +19 -0
- data/templates/frontend/app/page.module.css +315 -0
- data/templates/frontend/app/page.tsx.tt +183 -0
- data/templates/frontend/app/providers.tsx +24 -0
- data/templates/frontend/lib/gemstack/client.ts +125 -0
- data/templates/frontend/next-env.d.ts +5 -0
- data/templates/frontend/next.config.ts +17 -0
- data/templates/frontend/package.json.tt +23 -0
- data/templates/frontend/tsconfig.json +21 -0
- data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
- data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
- data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
- data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
- data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
- data/templates/realtime/config/channels.rb +14 -0
- data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
- data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
- data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
- data/templates/resource/frontend/frontend/lib/format.ts +12 -0
- data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +64 -0
- data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
- data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
- data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
- data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
- data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
- data/templates/storage/app/serializers/upload_serializer.rb +11 -0
- data/templates/storage/frontend/lib/upload.ts +52 -0
- data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
- 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
|