trane 0.1.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 +7 -0
- data/CHANGELOG.md +36 -0
- data/LICENSE.txt +21 -0
- data/README.md +151 -0
- data/lib/tasks/trane.rake +17 -0
- data/lib/trane/boot_validator.rb +64 -0
- data/lib/trane/configuration.rb +146 -0
- data/lib/trane/contract_loader.rb +29 -0
- data/lib/trane/contract_validator.rb +88 -0
- data/lib/trane/controller/error_handler.rb +169 -0
- data/lib/trane/controller/renderer.rb +104 -0
- data/lib/trane/controller.rb +26 -0
- data/lib/trane/docs/app.rb +36 -0
- data/lib/trane/docs/cache.rb +76 -0
- data/lib/trane/docs/html_renderer.rb +104 -0
- data/lib/trane/docs/service_definition.rb +173 -0
- data/lib/trane/docs/templates/index.html.erb +489 -0
- data/lib/trane/engine.rb +148 -0
- data/lib/trane/error_registry.rb +30 -0
- data/lib/trane/extra_attributes_filter.rb +41 -0
- data/lib/trane/field_builder.rb +81 -0
- data/lib/trane/field_node.rb +18 -0
- data/lib/trane/operation_definition.rb +146 -0
- data/lib/trane/param_definition.rb +25 -0
- data/lib/trane/registry.rb +324 -0
- data/lib/trane/representation_definition.rb +24 -0
- data/lib/trane/route_validator.rb +89 -0
- data/lib/trane/routing_extension.rb +85 -0
- data/lib/trane/serializer.rb +147 -0
- data/lib/trane/testing.rb +40 -0
- data/lib/trane/types.rb +67 -0
- data/lib/trane/version.rb +5 -0
- data/lib/trane.rb +111 -0
- metadata +167 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module Trane
|
|
6
|
+
module Controller
|
|
7
|
+
# Mixin that captures StandardError subclasses and maps them to
|
|
8
|
+
# registered Trane errors. Resolves the exception class via the
|
|
9
|
+
# precomputed errors_by_name index on the Registry snapshot —
|
|
10
|
+
# single lookup on FQDN match, with a short-name fallback (via
|
|
11
|
+
# String#rpartition) for hosts that registered errors by short
|
|
12
|
+
# name. Include in your API base controller. Does NOT add
|
|
13
|
+
# `render contract:` support — combine with Trane::Controller::Renderer
|
|
14
|
+
# or use the composer Trane::Controller.
|
|
15
|
+
#
|
|
16
|
+
# WARNING: this installs rescue_from StandardError at the class
|
|
17
|
+
# level. Including in a base controller that also serves HTML
|
|
18
|
+
# would catch errors Devise / Pundit / etc. expect to bubble up.
|
|
19
|
+
# Recommended: include only in API base controllers.
|
|
20
|
+
#
|
|
21
|
+
# Rails-reserved exceptions (those in
|
|
22
|
+
# ActionDispatch::ExceptionWrapper.rescue_responses, e.g.
|
|
23
|
+
# ActiveRecord::RecordNotFound → 404, ActionController::ParameterMissing → 400)
|
|
24
|
+
# are re-raised when no Trane error is registered for them, so Rails'
|
|
25
|
+
# exception middleware applies its default status mapping. Hosts that
|
|
26
|
+
# want to swallow these into the Trane envelope can register them
|
|
27
|
+
# explicitly via `Trane.errors { error "ActiveRecord::RecordNotFound", ... }`;
|
|
28
|
+
# the Trane lookup wins over the re-raise path.
|
|
29
|
+
#
|
|
30
|
+
# SECURITY NOTE: a registered error's envelope carries the exception's
|
|
31
|
+
# runtime #message, in EVERY environment including production. Framework
|
|
32
|
+
# and library exception messages are written for logs and may reveal
|
|
33
|
+
# internals (model names, query conditions). Prefer the recommended
|
|
34
|
+
# pattern (docs/wiki/Error-Handling.md) — rescue the framework exception
|
|
35
|
+
# and raise a domain error with a curated message — and register framework
|
|
36
|
+
# exceptions directly only when their messages are acceptable to expose.
|
|
37
|
+
#
|
|
38
|
+
# PRODUCTION NOTE: re-raised exceptions surface via Rails' default
|
|
39
|
+
# middleware. Keep `config.consider_all_requests_local = false` in
|
|
40
|
+
# production so `ActionDispatch::ShowExceptions` serves the static
|
|
41
|
+
# `public/404.html` / `500.html` pages. With `consider_all_requests_local`
|
|
42
|
+
# enabled in production, `ActionDispatch::DebugExceptions` would expose
|
|
43
|
+
# the exception class + message + backtrace.
|
|
44
|
+
module ErrorHandler
|
|
45
|
+
extend ActiveSupport::Concern
|
|
46
|
+
|
|
47
|
+
included do
|
|
48
|
+
rescue_from StandardError, with: :_trane_handle_error
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns the frozen Set of Rails-reserved exception class NAMES
|
|
52
|
+
# (Strings) derived from ActionDispatch::ExceptionWrapper.rescue_responses.
|
|
53
|
+
# Memoized on first call (after Rails boot completes). Returns an empty
|
|
54
|
+
# Set when Rails is not loaded.
|
|
55
|
+
#
|
|
56
|
+
# Names, not Class objects, on purpose: memoizing classes would pin
|
|
57
|
+
# host-registered (Zeitwerk-reloadable) exception constants in the gem
|
|
58
|
+
# forever, and after a development reload the stale Class would no
|
|
59
|
+
# longer match its reloaded replacement. Names survive reloads.
|
|
60
|
+
#
|
|
61
|
+
# NOTE: callers MUST invoke this post-boot. The Rails Engine initializers
|
|
62
|
+
# merge AR/AC entries into `rescue_responses` during boot; calling this
|
|
63
|
+
# earlier would memoize an incomplete list. In practice the first error
|
|
64
|
+
# arrives in a request thread, which is always post-boot — but tests or
|
|
65
|
+
# explicit pre-boot invocations would have to reset @rails_reserved_names
|
|
66
|
+
# afterwards.
|
|
67
|
+
def self.rails_reserved_names
|
|
68
|
+
@rails_reserved_names ||= begin
|
|
69
|
+
if defined?(ActionDispatch::ExceptionWrapper)
|
|
70
|
+
Set.new(ActionDispatch::ExceptionWrapper.rescue_responses.keys.grep(String)).freeze
|
|
71
|
+
else
|
|
72
|
+
Set.new.freeze
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def _trane_handle_error(exception)
|
|
80
|
+
klass = exception.class
|
|
81
|
+
return _trane_unhandled_error(exception) unless klass.name
|
|
82
|
+
|
|
83
|
+
index = Trane.registry.errors_by_name
|
|
84
|
+
error_def = index[klass.name] || _trane_short_name_match(index, klass)
|
|
85
|
+
|
|
86
|
+
if error_def
|
|
87
|
+
render(
|
|
88
|
+
json: { errors: [ { key: error_def.key.to_s, message: exception.message } ] },
|
|
89
|
+
status: error_def.status_code
|
|
90
|
+
)
|
|
91
|
+
elsif _trane_rails_reserved?(klass)
|
|
92
|
+
raise exception
|
|
93
|
+
else
|
|
94
|
+
_trane_unhandled_error(exception)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# rescue_from handles the exception BEFORE Rails' exception-reporting
|
|
99
|
+
# middleware can see it, so without this hook a 500 in production
|
|
100
|
+
# would leave no trace at all: no log line, no stacktrace, and no
|
|
101
|
+
# event in middleware-based error trackers. Report through
|
|
102
|
+
# Rails.error (the ErrorReporter interface trackers subscribe to)
|
|
103
|
+
# and write the class + message + backtrace to the log.
|
|
104
|
+
def _trane_report_unhandled(exception)
|
|
105
|
+
return unless defined?(Rails)
|
|
106
|
+
|
|
107
|
+
if Rails.respond_to?(:error) && Rails.error
|
|
108
|
+
Rails.error.report(exception, handled: true, source: "trane")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
if Rails.respond_to?(:logger) && Rails.logger
|
|
112
|
+
Rails.logger.error(
|
|
113
|
+
"[Trane] unhandled #{exception.class}: #{exception.message}\n" \
|
|
114
|
+
"#{Array(exception.backtrace).first(20).join("\n")}"
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Short-name fallback, restricted to errors REGISTERED by short name.
|
|
120
|
+
# The errors_by_name index also aliases FQDN registrations under their
|
|
121
|
+
# demodulized name (BootValidator resolves operation error_keys through
|
|
122
|
+
# those aliases), but matching here through an alias would let an
|
|
123
|
+
# unrelated exception (SomeGem::UserNotFound) hijack a registered
|
|
124
|
+
# Errors::UserNotFound and send a foreign library's message to the
|
|
125
|
+
# client — FQDN registrations must match exactly.
|
|
126
|
+
def _trane_short_name_match(index, klass)
|
|
127
|
+
short = klass.name.rpartition("::").last
|
|
128
|
+
candidate = index[short]
|
|
129
|
+
candidate if candidate && candidate.key == short
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def _trane_rails_reserved?(klass)
|
|
133
|
+
names = ErrorHandler.rails_reserved_names
|
|
134
|
+
return false if names.empty?
|
|
135
|
+
|
|
136
|
+
klass.ancestors.any? { |ancestor| names.include?(ancestor.name) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Verbose output is allow-listed to LOCAL environments (development
|
|
140
|
+
# and test, via Rails.env.local?) rather than deny-listed against
|
|
141
|
+
# production: a custom environment (staging, uat, preprod) must get
|
|
142
|
+
# the generic message by default. Exception messages are written by
|
|
143
|
+
# libraries that assume a log audience — they can carry SQL, record
|
|
144
|
+
# values, or internal hostnames — and this rescue_from renders before
|
|
145
|
+
# Rails' exception middleware, so the host's
|
|
146
|
+
# consider_all_requests_local setting cannot protect these responses.
|
|
147
|
+
def _trane_unhandled_error(exception)
|
|
148
|
+
_trane_report_unhandled(exception)
|
|
149
|
+
|
|
150
|
+
if defined?(Rails) && Rails.env.local?
|
|
151
|
+
render(
|
|
152
|
+
json: {
|
|
153
|
+
errors: [ {
|
|
154
|
+
key: "InternalServerError",
|
|
155
|
+
message: "#{exception.class}: #{exception.message}"
|
|
156
|
+
} ]
|
|
157
|
+
},
|
|
158
|
+
status: :internal_server_error
|
|
159
|
+
)
|
|
160
|
+
else
|
|
161
|
+
render(
|
|
162
|
+
json: { errors: [ { key: "InternalServerError", message: "An unexpected error occurred" } ] },
|
|
163
|
+
status: :internal_server_error
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "json"
|
|
5
|
+
require "rack/utils"
|
|
6
|
+
|
|
7
|
+
module Trane
|
|
8
|
+
module Controller
|
|
9
|
+
# Mixin that adds `render contract: data` support to a controller.
|
|
10
|
+
# Include in your API base controller (e.g. Api::BaseController <
|
|
11
|
+
# ActionController::API). Does NOT install any rescue_from handlers —
|
|
12
|
+
# combine with Trane::Controller::ErrorHandler or use the composer
|
|
13
|
+
# Trane::Controller for both.
|
|
14
|
+
#
|
|
15
|
+
# Caveat: if the including class also `prepend`s a custom `#render`,
|
|
16
|
+
# the prepended one runs first; chain via `super` to reach this one.
|
|
17
|
+
module Renderer
|
|
18
|
+
extend ActiveSupport::Concern
|
|
19
|
+
|
|
20
|
+
def render(options = nil, extra_options = {}, &block)
|
|
21
|
+
if options.is_a?(Hash) && options.key?(:contract)
|
|
22
|
+
if extra_options.is_a?(Hash) && extra_options.key?(:callback)
|
|
23
|
+
raise ArgumentError,
|
|
24
|
+
"Trane: render contract: does not support :callback (JSONP). " \
|
|
25
|
+
"Use render json: directly if JSONP is required."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
contract_data = options[:contract]
|
|
29
|
+
status = options[:status] || :ok
|
|
30
|
+
status_int = ::Rack::Utils.status_code(status)
|
|
31
|
+
|
|
32
|
+
op_name = _trane_operation_name
|
|
33
|
+
unless op_name
|
|
34
|
+
# The route did not inject _trane_operation, so no contract can
|
|
35
|
+
# be resolved and the field filtering cannot run. Serving the
|
|
36
|
+
# data unserialized would expose every attribute of the object
|
|
37
|
+
# (fail-open on the gem's only field filter), so the default is
|
|
38
|
+
# to fail loud, consistent with the unknown-operation and
|
|
39
|
+
# missing-response paths below. Hosts opt into the old behavior
|
|
40
|
+
# via config.on_missing_operation = :log / :fallback.
|
|
41
|
+
case Trane.configuration.on_missing_operation
|
|
42
|
+
when :fallback
|
|
43
|
+
return super(json: contract_data, status: status, **extra_options, &block)
|
|
44
|
+
when :log
|
|
45
|
+
_trane_log_missing_operation
|
|
46
|
+
return super(json: contract_data, status: status, **extra_options, &block)
|
|
47
|
+
else
|
|
48
|
+
raise Trane::Error,
|
|
49
|
+
"Trane: render contract: was called but the route did not declare a contract, " \
|
|
50
|
+
"so the response cannot be serialized or filtered. " \
|
|
51
|
+
"Add `contract: { operation: :<operation_name> }` to this route in routes.rb, " \
|
|
52
|
+
"or set `config.on_missing_operation` to :log or :fallback to serve the data unserialized."
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
registry = Trane.registry
|
|
57
|
+
|
|
58
|
+
op = registry.operations[op_name]
|
|
59
|
+
unless op
|
|
60
|
+
raise Trane::Error,
|
|
61
|
+
"Trane: no operation registered for '#{op_name}'. " \
|
|
62
|
+
"Define it with Trane.operation(:#{op_name}) { ... }"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
response_def = op.responses[status_int]
|
|
66
|
+
unless response_def
|
|
67
|
+
raise Trane::Error,
|
|
68
|
+
"Trane: operation '#{op_name}' has no response defined for status #{status_int}."
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
extra_attrs = ExtraAttributesFilter.parse(params)
|
|
72
|
+
strict = Trane.configuration.effective_strict_mode
|
|
73
|
+
serializer = registry.compiled_serializer_for(response_def, strict)
|
|
74
|
+
hash = serializer.serialize(contract_data, extra_attributes: extra_attrs)
|
|
75
|
+
|
|
76
|
+
body = ::JSON.generate(hash)
|
|
77
|
+
super(
|
|
78
|
+
plain: body,
|
|
79
|
+
status: status,
|
|
80
|
+
content_type: extra_options.fetch(:content_type, "application/json; charset=utf-8"),
|
|
81
|
+
**extra_options.except(:content_type),
|
|
82
|
+
&block
|
|
83
|
+
)
|
|
84
|
+
else
|
|
85
|
+
super
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def _trane_operation_name
|
|
92
|
+
op = request.path_parameters[:_trane_operation]
|
|
93
|
+
op&.to_sym
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def _trane_log_missing_operation
|
|
97
|
+
Trane.log_warning(
|
|
98
|
+
"[Trane] render contract: called on a route without contract metadata; " \
|
|
99
|
+
"serving unserialized JSON. Add `contract: { operation: ... }` to the route in routes.rb."
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require_relative "controller/renderer"
|
|
5
|
+
require_relative "controller/error_handler"
|
|
6
|
+
|
|
7
|
+
module Trane
|
|
8
|
+
# Convenience composer that includes both Trane::Controller::Renderer
|
|
9
|
+
# and Trane::Controller::ErrorHandler. Use this when you want both
|
|
10
|
+
# behaviors. For finer control (e.g., render override without the
|
|
11
|
+
# rescue_from), include the submodules directly.
|
|
12
|
+
#
|
|
13
|
+
# WARNING: include this only in API-specific base controllers (e.g.
|
|
14
|
+
# Api::BaseController < ActionController::API). The ErrorHandler
|
|
15
|
+
# piece captures StandardError subclasses globally — including in
|
|
16
|
+
# ApplicationController of an app that mixes HTML/JSON would
|
|
17
|
+
# intercept exceptions Devise / Pundit / etc. expect to bubble up.
|
|
18
|
+
module Controller
|
|
19
|
+
extend ActiveSupport::Concern
|
|
20
|
+
|
|
21
|
+
included do
|
|
22
|
+
include Renderer
|
|
23
|
+
include ErrorHandler
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trane
|
|
4
|
+
module Docs
|
|
5
|
+
# Rack middleware-style app that serves documentation endpoints.
|
|
6
|
+
# Mounted by the Trane::Engine at the path chosen by the host application.
|
|
7
|
+
class App
|
|
8
|
+
def call(env)
|
|
9
|
+
request = Rack::Request.new(env)
|
|
10
|
+
if request.path_info.end_with?(".json")
|
|
11
|
+
serve_json
|
|
12
|
+
else
|
|
13
|
+
serve_html
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def serve_json
|
|
20
|
+
[ 200, response_headers("application/json; charset=utf-8"), [ Cache.json ] ]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def serve_html
|
|
24
|
+
[ 200, response_headers("text/html; charset=utf-8"), [ Cache.html ] ]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# nosniff keeps browsers from second-guessing the declared MIME type.
|
|
28
|
+
def response_headers(content_type)
|
|
29
|
+
{
|
|
30
|
+
"content-type" => content_type,
|
|
31
|
+
"x-content-type-options" => "nosniff"
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Trane
|
|
6
|
+
module Docs
|
|
7
|
+
# Memoized JSON + HTML output of the ServiceDefinition. The pair lives
|
|
8
|
+
# inside a single frozen Snapshot ivar built atomically by
|
|
9
|
+
# `precompute!`: every Snapshot instance carries a `(json, html)`
|
|
10
|
+
# pair derived from one ServiceDefinition.generate call, so no
|
|
11
|
+
# caller can observe a torn pair WITHIN a single snapshot
|
|
12
|
+
# generation. The previous design wrote `@json` and `@html` in
|
|
13
|
+
# sequence, leaving a window where a reader saw new JSON paired
|
|
14
|
+
# with old HTML across a Rails reload.
|
|
15
|
+
#
|
|
16
|
+
# Consistency is per-snapshot-instance, not per-call-pair: a thread
|
|
17
|
+
# that reads `Cache.json` then `Cache.html` may observe two
|
|
18
|
+
# different snapshots if `precompute!` runs between the two calls.
|
|
19
|
+
# In production this is unobservable (each Rack request hits ONE of
|
|
20
|
+
# `/docs.json` or `/docs`, never both), and cross-request
|
|
21
|
+
# ordering across reloads is out of scope for this module.
|
|
22
|
+
#
|
|
23
|
+
# `precompute!` is ALWAYS lazy — triggered by the first `json`/`html`
|
|
24
|
+
# read via `ensure_snapshot`. Trane::Engine's `to_prepare` block calls
|
|
25
|
+
# `invalidate!` (not `precompute!`): during `to_prepare` the host routes
|
|
26
|
+
# are not drawn yet (it runs before the Finisher's
|
|
27
|
+
# set_routes_reloader_hook, in every environment), so precomputing there
|
|
28
|
+
# would build a snapshot from an empty route set and every operation would
|
|
29
|
+
# fall back to method "GET" with an empty path. Deferring to the first
|
|
30
|
+
# post-boot read — inside a request, with the routes drawn — is what makes
|
|
31
|
+
# the docs report each operation's real HTTP verb and path. Concurrent
|
|
32
|
+
# cold readers may both build a snapshot; one assignment wins, the other is
|
|
33
|
+
# GC'd — wasted work, never inconsistent state.
|
|
34
|
+
module Cache
|
|
35
|
+
Snapshot = Data.define(:json, :html)
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
def json
|
|
39
|
+
ensure_snapshot
|
|
40
|
+
@snapshot&.json
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def html
|
|
44
|
+
ensure_snapshot
|
|
45
|
+
@snapshot&.html
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Build a fresh (json, html) Snapshot from the current
|
|
49
|
+
# ServiceDefinition and swap it in atomically.
|
|
50
|
+
#
|
|
51
|
+
# @return [void]
|
|
52
|
+
def precompute!
|
|
53
|
+
# The only Rails read in the docs layer: the service name is
|
|
54
|
+
# Rails.application.name, never Trane configuration.
|
|
55
|
+
app = Rails.application
|
|
56
|
+
definition = ServiceDefinition.generate(app.routes.routes, service_name: app.name)
|
|
57
|
+
@snapshot = Snapshot.new(
|
|
58
|
+
json: JSON.generate(definition),
|
|
59
|
+
html: HtmlRenderer.render(definition)
|
|
60
|
+
)
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def invalidate!
|
|
65
|
+
@snapshot = nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def ensure_snapshot
|
|
71
|
+
precompute! if @snapshot.nil?
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module Trane
|
|
6
|
+
module Docs
|
|
7
|
+
class HtmlRenderer
|
|
8
|
+
METHOD_COLORS = {
|
|
9
|
+
"GET" => "#61affe",
|
|
10
|
+
"POST" => "#49cc90",
|
|
11
|
+
"PUT" => "#fca130",
|
|
12
|
+
"PATCH" => "#fca130",
|
|
13
|
+
"DELETE" => "#f93e3e"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
TEMPLATE_PATH = File.expand_path("templates/index.html.erb", __dir__)
|
|
17
|
+
TEMPLATE = ERB.new(File.read(TEMPLATE_PATH), trim_mode: "-").freeze
|
|
18
|
+
|
|
19
|
+
def self.render(definition)
|
|
20
|
+
new(definition).to_html
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(definition)
|
|
24
|
+
@definition = definition
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_html
|
|
28
|
+
TEMPLATE.result(binding)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def service
|
|
34
|
+
@definition[:service]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def operations
|
|
38
|
+
@definition[:operations] || []
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def representations
|
|
42
|
+
@definition[:representations] || []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def errors
|
|
46
|
+
@definition[:errors] || []
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def method_color(method)
|
|
50
|
+
METHOD_COLORS[method.to_s.upcase] || "#999"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def h(text)
|
|
54
|
+
ERB::Util.html_escape(text.to_s)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def representation_link(type_name)
|
|
58
|
+
type_str = type_name.to_s
|
|
59
|
+
reps = representations.map { |r| r[:name] }
|
|
60
|
+
if reps.include?(type_str)
|
|
61
|
+
"<a href=\"#rep-#{h(type_str)}\" class=\"type-link\">#{h(type_str)}</a>"
|
|
62
|
+
else
|
|
63
|
+
"<span class=\"type\">#{h(type_str)}</span>"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def render_field_row(field, indent = 0, include_required: false, buffer: +"")
|
|
68
|
+
name = h(field[:name])
|
|
69
|
+
type = field[:type] || "object"
|
|
70
|
+
type_display = if field[:array_of]
|
|
71
|
+
"array of #{representation_link(field[:array_of])}"
|
|
72
|
+
else
|
|
73
|
+
representation_link(type)
|
|
74
|
+
end
|
|
75
|
+
extra_badge = field[:extra] ? ' <span class="badge extra">extra</span>' : ""
|
|
76
|
+
format_badge = field[:format] ? " <span class=\"badge format\">#{h(field[:format])}</span>" : ""
|
|
77
|
+
enum_display = field[:enum] ? %(<div class="enum-values">enum: #{field[:enum].map { |v| h(v) }.join(", ")}</div>) : ""
|
|
78
|
+
padding = indent * 20
|
|
79
|
+
|
|
80
|
+
buffer << %(<tr><td style="padding-left: #{padding + 12}px"><code>#{name}</code>#{extra_badge}</td>)
|
|
81
|
+
buffer << %(<td>#{type_display}#{format_badge}#{enum_display}</td>)
|
|
82
|
+
|
|
83
|
+
if include_required
|
|
84
|
+
required_badge = case field[:required]
|
|
85
|
+
when true then '<span class="badge required">required</span>'
|
|
86
|
+
when false then '<span class="badge optional">optional</span>'
|
|
87
|
+
else "—"
|
|
88
|
+
end
|
|
89
|
+
buffer << %(<td>#{required_badge}</td>)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
buffer << "</tr>\n"
|
|
93
|
+
|
|
94
|
+
if field[:children]
|
|
95
|
+
field[:children].each do |child|
|
|
96
|
+
render_field_row(child, indent + 1, include_required: include_required, buffer: buffer)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
buffer
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Trane
|
|
6
|
+
module Docs
|
|
7
|
+
class ServiceDefinition
|
|
8
|
+
# Generate a complete service definition hash from the registry and routes.
|
|
9
|
+
#
|
|
10
|
+
# @param routes [ActionDispatch::Routing::RouteSet::NamedRouteCollection, Array] Rails routes
|
|
11
|
+
# @param service_name [String] published as `service.name`. Trane does not
|
|
12
|
+
# configure the API name: callers inside a Rails application pass
|
|
13
|
+
# `Rails.application.name`. Required — there is no default.
|
|
14
|
+
# @return [Hash]
|
|
15
|
+
def self.generate(routes, service_name:)
|
|
16
|
+
new(routes, service_name: service_name).to_h
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(routes, service_name:)
|
|
20
|
+
@routes = routes
|
|
21
|
+
@service_name = service_name
|
|
22
|
+
@registry = Trane.registry
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
{
|
|
27
|
+
service: {
|
|
28
|
+
name: @service_name
|
|
29
|
+
},
|
|
30
|
+
operations: build_operations,
|
|
31
|
+
representations: build_representations,
|
|
32
|
+
errors: build_errors
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def build_operations
|
|
39
|
+
route_map = extract_route_map
|
|
40
|
+
|
|
41
|
+
@registry.operations.map do |name, op|
|
|
42
|
+
route_info = route_map[name.to_s] || {}
|
|
43
|
+
|
|
44
|
+
entry = {
|
|
45
|
+
id: name.to_s,
|
|
46
|
+
summary: op.summary,
|
|
47
|
+
method: route_info[:method] || "GET",
|
|
48
|
+
path: route_info[:path] || ""
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
entry[:request] = build_request(op.request) if op.request
|
|
52
|
+
entry[:responses] = build_responses(op.responses)
|
|
53
|
+
entry[:errors] = op.error_keys.map(&:to_s) unless op.error_keys.empty?
|
|
54
|
+
|
|
55
|
+
entry
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_request(request_def)
|
|
60
|
+
result = {}
|
|
61
|
+
|
|
62
|
+
unless request_def.params.empty?
|
|
63
|
+
result[:params] = request_def.params.map do |p|
|
|
64
|
+
entry = { name: p.name.to_s, type: p.type.to_s, location: p.location.to_s, required: p.required }
|
|
65
|
+
entry[:enum] = serialize_enum(p.enum) unless p.enum.nil?
|
|
66
|
+
entry
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
unless request_def.body_fields.empty?
|
|
71
|
+
result[:body] = build_field_list(request_def.body_fields)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
result
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def build_responses(responses)
|
|
78
|
+
responses.map do |status, resp|
|
|
79
|
+
{ status: status, fields: build_field_list(resp.fields) }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_field_list(fields)
|
|
84
|
+
fields.map { |f| build_field_hash(f) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_field_hash(field)
|
|
88
|
+
entry = { name: field.name.to_s }
|
|
89
|
+
entry[:type] = field.type.to_s if field.type
|
|
90
|
+
entry[:format] = field.format.to_s if field.format
|
|
91
|
+
entry[:extra] = true if field.extra
|
|
92
|
+
entry[:array_of] = field.array_of.to_s if field.array_of
|
|
93
|
+
entry[:required] = field.required unless field.required.nil?
|
|
94
|
+
entry[:enum] = serialize_enum(field.enum) unless field.enum.nil?
|
|
95
|
+
|
|
96
|
+
unless field.children.empty?
|
|
97
|
+
entry[:children] = build_field_list(field.children)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
entry
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def build_representations
|
|
104
|
+
@registry.representations.map do |name, rep|
|
|
105
|
+
{
|
|
106
|
+
name: name.to_s,
|
|
107
|
+
fields: build_field_list(rep.fields)
|
|
108
|
+
}
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def build_errors
|
|
113
|
+
@registry.errors.map do |_key, err|
|
|
114
|
+
{
|
|
115
|
+
key: err.key.to_s,
|
|
116
|
+
status_code: err.status_code,
|
|
117
|
+
description: err.description
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def serialize_enum(values)
|
|
123
|
+
values.map do |v|
|
|
124
|
+
case v
|
|
125
|
+
when Date, DateTime, Time then v.iso8601
|
|
126
|
+
else v
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def extract_route_map
|
|
132
|
+
map = {}
|
|
133
|
+
|
|
134
|
+
@routes.each do |route|
|
|
135
|
+
defaults = route.defaults
|
|
136
|
+
op_name = defaults[:_trane_operation]
|
|
137
|
+
next unless op_name
|
|
138
|
+
|
|
139
|
+
path = route.path.spec.to_s.gsub("(.:format)", "")
|
|
140
|
+
method = extract_method(route)
|
|
141
|
+
|
|
142
|
+
# Store only the first route per operation: defensive against
|
|
143
|
+
# any host that explicitly declares the same _trane_operation on
|
|
144
|
+
# multiple `match` lines. Rails 8.1.2 collapses `via: [:patch,
|
|
145
|
+
# :put]` into a single route with verb "PATCH|PUT", so this
|
|
146
|
+
# `||=` is a no-op for that case (the verb is canonicalised by
|
|
147
|
+
# `extract_method` to "PATCH").
|
|
148
|
+
map[op_name] ||= { method: method, path: path }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
map
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# NOTE: Trane::RouteValidator guarantees that a route with a contract
|
|
155
|
+
# maps to exactly one HTTP verb, so the `"PATCH|PUT"` (String) and
|
|
156
|
+
# `/^PATCH|PUT$/` (Regexp) multi-verb branches below no longer occur at
|
|
157
|
+
# runtime for contract routes. They are kept as defensive fallbacks.
|
|
158
|
+
def extract_method(route)
|
|
159
|
+
verb = route.verb
|
|
160
|
+
case verb
|
|
161
|
+
when String then verb.split("|").first
|
|
162
|
+
when Regexp then verb.source.scan(/[A-Z]+/).first || verb.source
|
|
163
|
+
else
|
|
164
|
+
if verb.respond_to?(:verb)
|
|
165
|
+
verb.verb.to_s
|
|
166
|
+
else
|
|
167
|
+
verb.to_s
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|