haveapi 0.31.1 → 0.32.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/.gitignore +2 -0
- data/benchmarks/documentation.rb +29 -6
- data/benchmarks/endpoints.rb +153 -24
- data/benchmarks/spec/client_examples_spec.rb +109 -37
- data/doc/client-examples.md +218 -45
- data/doc/documentation-oauth.md +157 -0
- data/doc/documentation-ui.md +192 -0
- data/doc/index.md +3 -6
- data/doc/performance.md +39 -12
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/authentication/chain.rb +19 -0
- data/lib/haveapi/authentication/oauth2/provider.rb +7 -1
- data/lib/haveapi/client_example.rb +143 -15
- data/lib/haveapi/client_examples/curl.rb +30 -20
- data/lib/haveapi/client_examples/dynamic_helpers.rb +1 -1
- data/lib/haveapi/client_examples/go_client.rb +9 -2
- data/lib/haveapi/client_examples/http.rb +9 -4
- data/lib/haveapi/client_examples/js_async_client.rb +7 -0
- data/lib/haveapi/client_examples/js_client.rb +47 -36
- data/lib/haveapi/client_examples/php_client.rb +161 -75
- data/lib/haveapi/client_examples/python_async_client.rb +3 -0
- data/lib/haveapi/client_examples/python_client.rb +61 -47
- data/lib/haveapi/client_examples/ruby_cli.rb +27 -18
- data/lib/haveapi/client_examples/ruby_client.rb +80 -31
- data/lib/haveapi/client_examples/rust_client.rb +20 -9
- data/lib/haveapi/client_examples/ts_async_client.rb +7 -0
- data/lib/haveapi/client_examples/ts_client.rb +3 -0
- data/lib/haveapi/client_examples/ts_generated_client.rb +10 -2
- data/lib/haveapi/documentation_context.rb +7 -2
- data/lib/haveapi/documentation_helpers.rb +223 -0
- data/lib/haveapi/documentation_oauth.rb +399 -0
- data/lib/haveapi/documentation_search.rb +225 -0
- data/lib/haveapi/documentation_search_index.rb +79 -0
- data/lib/haveapi/documentation_validators.rb +179 -0
- data/lib/haveapi/public/css/documentation.css +343 -0
- data/lib/haveapi/public/js/documentation.js +992 -0
- data/lib/haveapi/server.rb +144 -86
- data/lib/haveapi/shared_documentation_helpers.rb +54 -0
- data/lib/haveapi/version.rb +1 -1
- data/lib/haveapi/views/doc_layout.erb +16 -0
- data/lib/haveapi/views/doc_sidebars/create-client.erb +3 -3
- data/lib/haveapi/views/doc_sidebars/json-schema.erb +1 -1
- data/lib/haveapi/views/doc_sidebars/protocol.erb +5 -5
- data/lib/haveapi/views/documentation/search_form.erb +10 -0
- data/lib/haveapi/views/documentation/search_index.erb +1 -0
- data/lib/haveapi/views/documentation/search_results.erb +20 -0
- data/lib/haveapi/views/index.erb +2 -4
- data/lib/haveapi/views/main_layout.erb +12 -6
- data/lib/haveapi/views/shared_documentation.erb +12 -0
- data/lib/haveapi/views/shared_sidebar.erb +25 -0
- data/lib/haveapi/views/version_page/auth_body.erb +12 -33
- data/lib/haveapi/views/version_page/client_overview.erb +40 -0
- data/lib/haveapi/views/version_page/example_panels.erb +18 -0
- data/lib/haveapi/views/version_page/resource_body.erb +91 -132
- data/lib/haveapi/views/version_page/resource_directory.erb +14 -0
- data/lib/haveapi/views/version_page/usage.erb +7 -0
- data/lib/haveapi/views/version_page.erb +36 -117
- data/lib/haveapi/views/version_sidebar/auth_nav.erb +12 -16
- data/lib/haveapi/views/version_sidebar/auth_resource_nav.erb +19 -0
- data/lib/haveapi/views/version_sidebar/authentication_control.erb +26 -0
- data/lib/haveapi/views/version_sidebar/authentication_status.erb +3 -0
- data/lib/haveapi/views/version_sidebar/brand.erb +31 -0
- data/lib/haveapi/views/version_sidebar/resource_nav.erb +11 -20
- data/lib/haveapi/views/version_sidebar.erb +59 -48
- data/spec/authentication/basic_spec.rb +10 -10
- data/spec/authentication/oauth2_chain_spec.rb +72 -0
- data/spec/authentication/oauth2_token_validation_spec.rb +38 -0
- data/spec/client_examples/assets/documentation_test.cjs +817 -0
- data/spec/client_examples/context_performance_spec.rb +5 -4
- data/spec/client_examples/context_spec.rb +23 -2
- data/spec/client_examples/detail_spec.rb +175 -0
- data/spec/client_examples/dynamic_acceptance.md +59 -26
- data/spec/client_examples/dynamic_acceptance.rb +164 -44
- data/spec/client_examples/dynamic_acceptance_spec.rb +140 -23
- data/spec/client_examples/dynamic_fixture.rb +4 -3
- data/spec/client_examples/dynamic_js_spec.rb +20 -11
- data/spec/client_examples/dynamic_metadata_spec.rb +69 -0
- data/spec/client_examples/dynamic_mounted_paths_spec.rb +9 -7
- data/spec/client_examples/dynamic_python_spec.rb +62 -11
- data/spec/client_examples/go_spec.rb +14 -1
- data/spec/client_examples/presentation_spec.rb +247 -0
- data/spec/client_examples/profile_mount_spec.rb +40 -13
- data/spec/client_examples/rust_client_spec.rb +35 -1
- data/spec/client_examples/shell_spec.rb +268 -0
- data/spec/client_examples/syntax_spec.rb +192 -0
- data/spec/client_examples/ts_generated_client_spec.rb +15 -2
- data/spec/client_examples/typescript_profiles_spec.rb +1 -1
- data/spec/documentation/auth_filtering_spec.rb +9 -4
- data/spec/documentation/canonical_routes_spec.rb +89 -0
- data/spec/documentation/client_examples_spec.rb +39 -5
- data/spec/documentation/client_overview_spec.rb +145 -0
- data/spec/documentation/current_user_html_escaping_spec.rb +7 -2
- data/spec/documentation/example_variants_spec.rb +93 -0
- data/spec/documentation/fixtures/search_index.json +476 -0
- data/spec/documentation/identity_and_guides_spec.rb +130 -0
- data/spec/documentation/identity_context_spec.rb +180 -0
- data/spec/documentation/navigation_spec.rb +296 -0
- data/spec/documentation/oauth_fixture_spec.rb +39 -0
- data/spec/documentation/oauth_spec.rb +912 -0
- data/spec/documentation/oauth_transport_spec.rb +45 -0
- data/spec/documentation/performance_spec.rb +47 -6
- data/spec/documentation/reference_headings_spec.rb +190 -0
- data/spec/documentation/search_index_routes_spec.rb +202 -0
- data/spec/documentation/search_index_spec.rb +85 -0
- data/spec/documentation/search_routes_spec.rb +199 -0
- data/spec/documentation/search_spec.rb +134 -0
- data/spec/documentation/shared_pages_spec.rb +240 -0
- data/spec/documentation/validators_spec.rb +123 -0
- data/spec/documentation_spec.rb +3 -3
- data/spec/packaging_spec.rb +21 -2
- data/spec/performance/documentation_identity_fixture_spec.rb +47 -0
- data/spec/performance/documentation_shapes_spec.rb +97 -0
- data/spec/performance/json_activation_spec.rb +78 -1
- data/spec/performance/release_lineage_spec.rb +4 -3
- data/spec/performance/runner_spec.rb +1 -0
- data/test_support/all_client_performance_fixture.rb +6 -10
- data/test_support/documentation_oauth_fixture.rb +54 -0
- data/test_support/documentation_performance_fixture.rb +19 -1
- data/test_support/dynamic_documentation_api.rb +35 -10
- data/test_support/dynamic_python_login_spec.rb +3 -11
- metadata +52 -3
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
require 'cgi'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'uri'
|
|
4
|
+
require 'haveapi/documentation_search_index'
|
|
5
|
+
|
|
6
|
+
module HaveAPI
|
|
7
|
+
class DocumentationSearch
|
|
8
|
+
LIMIT = 20
|
|
9
|
+
KIND_ORDER = { resource: 0, action: 1, parameter: 2 }.freeze
|
|
10
|
+
|
|
11
|
+
def self.parameter_anchor(resource_path:, action_name:, direction:, parameter_name:, authentication: nil)
|
|
12
|
+
tuple = [authentication&.to_s, resource_path.map(&:to_s), action_name.to_s, direction.to_s, parameter_name.to_s]
|
|
13
|
+
"parameter-#{JSON.generate(tuple).unpack1('H*')}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(index:, query:)
|
|
17
|
+
@index = index
|
|
18
|
+
@query = query
|
|
19
|
+
@needle = query.downcase
|
|
20
|
+
@tokens = @needle.split(/[[:space:]]+/)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
@matches = []
|
|
25
|
+
records unless @tokens.empty?
|
|
26
|
+
{ query: @query, results: @matches.first(LIMIT).map(&:last), more: @matches.length > LIMIT }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def field(hash, key, default = nil)
|
|
32
|
+
return default unless hash.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
hash.fetch(key) { hash.fetch(key.to_s, default) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def records
|
|
38
|
+
field(@index, :resources).each do |resource|
|
|
39
|
+
path = field(resource, :path)
|
|
40
|
+
record = resource_record(resource)
|
|
41
|
+
match(record, field(resource, :search), [path.last, path.join('.')])
|
|
42
|
+
end
|
|
43
|
+
field(@index, :actions).each do |action|
|
|
44
|
+
resource = field(@index, :resources).fetch(field(action, :resource))
|
|
45
|
+
record = action_record(resource, action)
|
|
46
|
+
match(record, action_metadata(resource, action), field(action, :identifiers))
|
|
47
|
+
end
|
|
48
|
+
field(@index, :parameters).each do |parameter|
|
|
49
|
+
action = field(@index, :actions).fetch(field(parameter, :action))
|
|
50
|
+
resource = field(@index, :resources).fetch(field(action, :resource))
|
|
51
|
+
record = action_record(resource, action).merge(kind: :parameter, name: field(parameter, :name),
|
|
52
|
+
label: field(parameter, :label), type: field(parameter, :type),
|
|
53
|
+
direction: field(parameter, :direction).to_sym)
|
|
54
|
+
record[:anchor] = self.class.parameter_anchor(resource_path: record[:resource_path], action_name: record[:action_name],
|
|
55
|
+
direction: record[:direction], parameter_name: record[:name], authentication: record[:authentication])
|
|
56
|
+
metadata = action_metadata(resource, action) + %i[name label type direction humanized_name].map { |name| field(parameter, name) }
|
|
57
|
+
match(record, metadata, [record[:name]])
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def resource_record(resource)
|
|
62
|
+
path = field(resource, :path)
|
|
63
|
+
{ kind: :resource, resource_path: path, name: path.last, label: field(resource, :label),
|
|
64
|
+
resource_label: resource_label(resource),
|
|
65
|
+
action_name: nil, direction: nil, type: nil, authentication: field(resource, :authentication),
|
|
66
|
+
anchor: "root-#{path.join('-')}" }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def resource_label(resource)
|
|
70
|
+
@resource_labels ||= field(@index, :resources).to_h do |entry|
|
|
71
|
+
[[field(entry, :authentication), field(entry, :path)], field(entry, :label)]
|
|
72
|
+
end
|
|
73
|
+
path = field(resource, :path)
|
|
74
|
+
path.each_index.map do |index|
|
|
75
|
+
@resource_labels.fetch([field(resource, :authentication), path.first(index + 1)], path[index])
|
|
76
|
+
end.join(' · ')
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def action_record(resource, action)
|
|
80
|
+
name = field(action, :name)
|
|
81
|
+
resource_record(resource).merge(kind: :action, name:, label: field(action, :label), action_name: name,
|
|
82
|
+
anchor: "root-#{field(resource, :path).join('-')}-#{name}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def action_metadata(resource, action)
|
|
86
|
+
field(resource, :search) + field(action, :identifiers) + field(action, :search)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def match(record, metadata, identifiers)
|
|
90
|
+
searchable = metadata.compact.map { |value| value.to_s.downcase }
|
|
91
|
+
text = searchable.join(' ')
|
|
92
|
+
return unless @tokens.all? { |token| text.include?(token) }
|
|
93
|
+
|
|
94
|
+
names = identifiers.compact.map { |value| value.to_s.downcase }
|
|
95
|
+
rank = if names.include?(@needle)
|
|
96
|
+
0
|
|
97
|
+
elsif names.any? { |name| name.start_with?(@needle) }
|
|
98
|
+
1
|
|
99
|
+
else
|
|
100
|
+
2
|
|
101
|
+
end
|
|
102
|
+
kind = KIND_ORDER.fetch(record[:kind])
|
|
103
|
+
key = [kind, rank, record[:authentication].to_s, record[:resource_path].join('.'),
|
|
104
|
+
record[:action_name].to_s, record[:name], record[:direction].to_s]
|
|
105
|
+
@matches << [key, record]
|
|
106
|
+
@matches.sort_by!(&:first)
|
|
107
|
+
@matches.pop if @matches.length > LIMIT + 1
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
module Helpers
|
|
111
|
+
def documentation_search_query
|
|
112
|
+
documentation_search_parameters[:query]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def documentation_search_parameters
|
|
116
|
+
return @documentation_search_parameters if defined?(@documentation_search_parameters)
|
|
117
|
+
|
|
118
|
+
pairs = request.query_string.split('&').map do |pair|
|
|
119
|
+
key, value = pair.split('=', 2)
|
|
120
|
+
[URI.decode_www_form_component(key), URI.decode_www_form_component(value || '')]
|
|
121
|
+
end
|
|
122
|
+
selected = pairs.select { |key, _| key.match?(/\Aq(?:\[|\z)/) }
|
|
123
|
+
unless selected.length <= 1 && selected.all? { |key, value| key == 'q' && value.valid_encoding? }
|
|
124
|
+
invalid_documentation_search_query
|
|
125
|
+
end
|
|
126
|
+
query = selected.empty? ? '' : selected.first.last.strip
|
|
127
|
+
invalid_documentation_search_query if query.length > 256
|
|
128
|
+
indices = pairs.select { |key, _| key.match?(/\Asearch_index(?:\[|\z)/) }
|
|
129
|
+
headers 'Cache-Control' => 'private, no-store' if indices.any?
|
|
130
|
+
if indices.any? && !(indices.length == 1 && indices.first[0] == 'search_index' &&
|
|
131
|
+
indices.first[1].match?(/\A(?:current|[0-9a-f]{64})\z/) && selected.empty? &&
|
|
132
|
+
documentation_selection == { 'page' => 'resources' })
|
|
133
|
+
invalid_documentation_search_query
|
|
134
|
+
end
|
|
135
|
+
@documentation_search_parameters = { query:, index: indices.first&.last }
|
|
136
|
+
rescue ArgumentError
|
|
137
|
+
invalid_documentation_search_query
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def invalid_documentation_search_query
|
|
141
|
+
headers 'Cache-Control' => 'private, no-store'
|
|
142
|
+
halt 400, 'Invalid documentation search query'
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def documentation_search_form_options(id:)
|
|
146
|
+
version = @v || settings.api_server.default_version
|
|
147
|
+
return unless version
|
|
148
|
+
|
|
149
|
+
{ id:, action: url(settings.api_server.version_prefix(version), false), version:,
|
|
150
|
+
query: @documentation_page == 'resources' ? documentation_search_query : '',
|
|
151
|
+
view_key: @help ? documentation_search_index.key : '',
|
|
152
|
+
index_url: documentation_search_index_url(version:, key: @help ? documentation_search_index.key : 'current') }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def documentation_search_index
|
|
156
|
+
@documentation_search_index ||= Index.new(description: @help, version: @v,
|
|
157
|
+
base_path: url(settings.api_server.version_prefix(@v), false),
|
|
158
|
+
locale: ::I18n.locale, visibility: current_user ? 'authenticated' : 'anonymous')
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def documentation_search_index_url(version:, key:)
|
|
162
|
+
"#{url(settings.api_server.version_prefix(version), false)}?page=resources&search_index=#{key}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def documentation_search_index_request?
|
|
166
|
+
!documentation_search_parameters[:index].nil?
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def render_documentation_search_index
|
|
170
|
+
headers 'Cache-Control' => 'private, no-store'
|
|
171
|
+
index = documentation_search_index
|
|
172
|
+
requested = documentation_search_parameters[:index]
|
|
173
|
+
if requested != 'current' && requested != index.key
|
|
174
|
+
halt 409, 'Search has changed. Reload this page or submit to open current results.'
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
content_type 'text/html', charset: 'utf-8'
|
|
178
|
+
add_vary_header('Cookie')
|
|
179
|
+
add_vary_header('Authorization')
|
|
180
|
+
body = erb :'documentation/search_index', layout: false, locals: { search_index: index }
|
|
181
|
+
state_changed = response.headers.any? { |name, value| name.downcase == 'set-cookie' && !Array(value).join.empty? }
|
|
182
|
+
if requested != 'current' && !state_changed
|
|
183
|
+
tag = %("#{index.key}")
|
|
184
|
+
headers 'Cache-Control' => 'private, max-age=86400, immutable', 'ETag' => tag
|
|
185
|
+
conditional = request.env['HTTP_IF_NONE_MATCH'].to_s.split(/\s*,\s*/)
|
|
186
|
+
halt 304, '' if conditional.any? { |value| [tag, "W/#{tag}", '*'].include?(value) }
|
|
187
|
+
end
|
|
188
|
+
body
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def documentation_parameter_anchor(**)
|
|
192
|
+
DocumentationSearch.parameter_anchor(**)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def documentation_search_results
|
|
196
|
+
@documentation_search_results ||= DocumentationSearch.new(index: documentation_search_index.payload, query: documentation_search_query).call
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def documentation_search_result_url(result)
|
|
200
|
+
if result[:authentication]
|
|
201
|
+
documentation_url(page: 'authentication', anchor: result[:anchor])
|
|
202
|
+
else
|
|
203
|
+
documentation_url(resource: result[:resource_path], anchor: result[:kind] == :resource ? nil : result[:anchor])
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def documentation_search_result_title(result)
|
|
208
|
+
result[:kind] == :resource ? result[:resource_path].join('.') : result[:name]
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def documentation_search_result_context(result)
|
|
212
|
+
context = []
|
|
213
|
+
if result[:kind] == :resource
|
|
214
|
+
context << result[:resource_label] unless result[:resource_label] == documentation_search_result_title(result)
|
|
215
|
+
else
|
|
216
|
+
context << "#{result[:resource_path].join('.')} · #{result[:action_name]}"
|
|
217
|
+
end
|
|
218
|
+
context << "#{result[:direction].to_s.capitalize} · #{result[:type]}" if result[:kind] == :parameter
|
|
219
|
+
context << "Authentication: #{result[:authentication]}" if result[:authentication]
|
|
220
|
+
context << result[:label] if result[:kind] != :resource && result[:label] && result[:label] != result[:name]
|
|
221
|
+
context
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
require 'digest'
|
|
2
|
+
require 'json'
|
|
3
|
+
|
|
4
|
+
module HaveAPI
|
|
5
|
+
class DocumentationSearch
|
|
6
|
+
class Index
|
|
7
|
+
attr_reader :payload, :key
|
|
8
|
+
|
|
9
|
+
def initialize(description:, base_path:, version:, locale:, visibility:)
|
|
10
|
+
@payload = { format: 1, version: text(version), base_path: text(base_path), locale: text(locale),
|
|
11
|
+
visibility: text(visibility), resources: [], actions: [], parameters: [] }
|
|
12
|
+
resources(field(description, :resources, {}))
|
|
13
|
+
field(description, :authentication, {}).sort_by { |name, _| name.to_s }.each do |provider, info|
|
|
14
|
+
resources(field(info, :resources, {}), authentication: text(provider))
|
|
15
|
+
end
|
|
16
|
+
freeze_data(@payload)
|
|
17
|
+
@key = Digest::SHA256.hexdigest(JSON.generate(@payload)).freeze
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def field(hash, name, default = nil)
|
|
24
|
+
return default unless hash.is_a?(Hash)
|
|
25
|
+
|
|
26
|
+
hash.fetch(name) { hash.fetch(name.to_s, default) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def text(value)
|
|
30
|
+
value.to_s.dup.freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def strings(values)
|
|
34
|
+
values.compact.map { |value| text(value) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def resources(tree, path = [], authentication: nil)
|
|
38
|
+
tree.sort_by { |name, _| name.to_s }.each do |name, resource|
|
|
39
|
+
selected = path + [text(name)]
|
|
40
|
+
label = text(field(resource, :label, name.to_s.humanize))
|
|
41
|
+
index = @payload[:resources].length
|
|
42
|
+
@payload[:resources] << { path: selected, label:, authentication:,
|
|
43
|
+
search: strings(selected.flat_map { |part| [part, part.humanize] } +
|
|
44
|
+
[selected.join('.'), label, authentication]) }
|
|
45
|
+
field(resource, :actions, {}).sort_by { |action_name, _| action_name.to_s }.each do |action_name, action|
|
|
46
|
+
action(index, selected, action_name, action)
|
|
47
|
+
end
|
|
48
|
+
resources(field(resource, :resources, {}), selected, authentication:)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def action(resource, path, name, description)
|
|
53
|
+
name = text(name)
|
|
54
|
+
index = @payload[:actions].length
|
|
55
|
+
@payload[:actions] << {
|
|
56
|
+
resource:, name:, label: text(field(description, :label, name.humanize)),
|
|
57
|
+
identifiers: strings([name, "#{path.join('.')}.#{name}", *Array(field(description, :aliases)), field(description, :path)]),
|
|
58
|
+
search: strings([name.humanize, field(description, :method), field(description, :label)])
|
|
59
|
+
}
|
|
60
|
+
%i[input output].each do |direction|
|
|
61
|
+
field(field(description, direction), :parameters, {}).sort_by { |parameter_name, _| parameter_name.to_s }.each do |parameter_name, parameter|
|
|
62
|
+
@payload[:parameters] << { action: index, name: text(parameter_name),
|
|
63
|
+
label: text(field(parameter, :label, parameter_name.to_s.humanize)),
|
|
64
|
+
type: text(field(parameter, :type)), direction: text(direction),
|
|
65
|
+
humanized_name: text(parameter_name.to_s.humanize) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def freeze_data(value)
|
|
71
|
+
case value
|
|
72
|
+
when Hash then value.each_value { |entry| freeze_data(entry) }
|
|
73
|
+
when Array then value.each { |entry| freeze_data(entry) }
|
|
74
|
+
end
|
|
75
|
+
value.freeze
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
require 'cgi'
|
|
2
|
+
require 'json'
|
|
3
|
+
|
|
4
|
+
module HaveAPI
|
|
5
|
+
# Presents already-authorized/localized metadata without changing validation.
|
|
6
|
+
class DocumentationValidators
|
|
7
|
+
def self.render(validators, type: nil)
|
|
8
|
+
return '' if validators.nil? || validators == {}
|
|
9
|
+
return new(type).data(validators) unless validators.is_a?(Hash)
|
|
10
|
+
|
|
11
|
+
renderer = new(type)
|
|
12
|
+
rules = validators.map { |name, options| renderer.rule(name, options) }.join
|
|
13
|
+
%(<div class="docs-validators"><table class="docs-validator-table"><caption>Validation rules</caption><thead><tr><th scope="col">Rule</th><th scope="col">Details</th></tr></thead><tbody>#{rules}</tbody></table></div>)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(type)
|
|
17
|
+
@type = type
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def rule(name, options)
|
|
21
|
+
content, used = explanation(name.to_s, options)
|
|
22
|
+
if content && options.is_a?(Hash)
|
|
23
|
+
remaining = options.reject { |key, _| used.include?(key.to_s) || key.to_s == 'message' }
|
|
24
|
+
content += "<p><strong>Validation error message:</strong> #{value(field(options, :message))}</p>" if option?(options, :message)
|
|
25
|
+
unless remaining.empty?
|
|
26
|
+
content += "<details><summary>Additional details</summary>#{data(remaining)}</details>"
|
|
27
|
+
end
|
|
28
|
+
elsif !content
|
|
29
|
+
content = "<p>Supplied validator metadata:</p>#{data(options)}"
|
|
30
|
+
end
|
|
31
|
+
known = %w[present include exclude accept confirm number length format custom].include?(name.to_s)
|
|
32
|
+
title = known ? name.to_s.capitalize : name.to_s
|
|
33
|
+
%(<tr class="docs-validator"><th scope="row">#{html(title)}</th><td>#{content}</td></tr>)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def explanation(name, options)
|
|
37
|
+
return ["<p>Application-specific rule: #{html(options)}</p>", []] if name == 'custom' && options.is_a?(String)
|
|
38
|
+
return unless options.is_a?(Hash)
|
|
39
|
+
# Two differently typed keys must not silently collapse to one option.
|
|
40
|
+
return if options.keys.map(&:to_s).uniq.length != options.length
|
|
41
|
+
|
|
42
|
+
case name
|
|
43
|
+
when 'present' then presence(options)
|
|
44
|
+
when 'include', 'exclude' then choices(name, options)
|
|
45
|
+
when 'accept'
|
|
46
|
+
return unless option?(options, :value)
|
|
47
|
+
|
|
48
|
+
["<p>Must equal #{literal(field(options, :value))}.</p>", ['value']]
|
|
49
|
+
when 'confirm'
|
|
50
|
+
return unless field(options, :parameter).is_a?(String) || field(options, :parameter).is_a?(Symbol)
|
|
51
|
+
return unless boolean?(field(options, :equal))
|
|
52
|
+
|
|
53
|
+
relation = field(options, :equal) ? 'Must equal' : 'Must differ from'
|
|
54
|
+
["<p>#{relation} parameter <code>#{html(field(options, :parameter))}</code>.</p>", %w[parameter equal]]
|
|
55
|
+
when 'number' then number(options)
|
|
56
|
+
when 'length' then length(options)
|
|
57
|
+
when 'format'
|
|
58
|
+
return unless field(options, :rx).is_a?(String) && boolean?(field(options, :match))
|
|
59
|
+
|
|
60
|
+
relation = field(options, :match) ? 'Must match' : 'Must not match'
|
|
61
|
+
content = "<p>#{relation} this regular expression:</p><pre><code>#{html(field(options, :rx))}</code></pre>"
|
|
62
|
+
content += "<p>#{value(field(options, :description))}</p>" if option?(options, :description)
|
|
63
|
+
[content, %w[rx match description]]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def presence(options)
|
|
68
|
+
return unless boolean?(field(options, :empty))
|
|
69
|
+
|
|
70
|
+
empty = field(options, :empty) ? 'Empty values are allowed.' : 'Must not be empty; strings must contain a non-whitespace character.'
|
|
71
|
+
["<p>Must be supplied and cannot be null. #{empty}</p>", ['empty']]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def choices(name, options)
|
|
75
|
+
values = field(options, :values)
|
|
76
|
+
return unless values.is_a?(Array) || (name == 'include' && values.is_a?(Hash))
|
|
77
|
+
|
|
78
|
+
label = name == 'include' ? 'Allowed values' : 'Disallowed values'
|
|
79
|
+
items = if values.is_a?(Hash)
|
|
80
|
+
values.map { |machine, label_text| "<li>#{choice_literal(machine)}: #{value(label_text)}</li>" }
|
|
81
|
+
else
|
|
82
|
+
values.map { |machine| "<li>#{choice_literal(machine)}</li>" }
|
|
83
|
+
end
|
|
84
|
+
body = items.empty? ? '<p>The set is empty.</p>' : %(<ul class="docs-validator-values">#{items.join}</ul>)
|
|
85
|
+
["<p>#{label}:</p>#{body}", ['values']]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def number(options)
|
|
89
|
+
numeric = %w[min max step mod]
|
|
90
|
+
return unless numeric.all? { |key| !option?(options, key) || finite_number?(field(options, key)) }
|
|
91
|
+
return unless %w[odd even].all? { |key| !option?(options, key) || boolean?(field(options, key)) }
|
|
92
|
+
|
|
93
|
+
lines = ['Must be a number after parameter type conversion.']
|
|
94
|
+
lines << "Minimum: #{literal(field(options, :min))} (inclusive)." if option?(options, :min)
|
|
95
|
+
lines << "Maximum: #{literal(field(options, :max))} (inclusive)." if option?(options, :max)
|
|
96
|
+
if option?(options, :step)
|
|
97
|
+
start = option?(options, :min) ? field(options, :min) : 0
|
|
98
|
+
lines << "Step: #{literal(field(options, :step))}, measured from #{literal(start)}."
|
|
99
|
+
end
|
|
100
|
+
lines << "Must be a multiple of #{literal(field(options, :mod))}, measured from zero." if option?(options, :mod)
|
|
101
|
+
lines << 'Must be odd.' if field(options, :odd) == true
|
|
102
|
+
lines << 'Must be even.' if field(options, :even) == true
|
|
103
|
+
lines << 'Odd-only restriction: disabled.' if option?(options, :odd) && field(options, :odd) == false
|
|
104
|
+
lines << 'Even-only restriction: disabled.' if option?(options, :even) && field(options, :even) == false
|
|
105
|
+
["<ul>#{lines.map { |line| "<li>#{line}</li>" }.join}</ul>", numeric + %w[odd even]]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def length(options)
|
|
109
|
+
keys = %w[equals min max]
|
|
110
|
+
return unless keys.any? { |key| option?(options, key) }
|
|
111
|
+
return unless keys.all? { |key| !option?(options, key) || field(options, key).is_a?(Integer) }
|
|
112
|
+
return if option?(options, :equals) && (option?(options, :min) || option?(options, :max))
|
|
113
|
+
|
|
114
|
+
unit = if %w[String Text].include?(@type)
|
|
115
|
+
' characters'
|
|
116
|
+
elsif %w[List Array].include?(@type)
|
|
117
|
+
' items'
|
|
118
|
+
else
|
|
119
|
+
''
|
|
120
|
+
end
|
|
121
|
+
lines = []
|
|
122
|
+
lines << "Length must equal #{literal(field(options, :equals))}#{unit}." if option?(options, :equals)
|
|
123
|
+
lines << "Minimum length: #{literal(field(options, :min))}#{unit} (inclusive)." if option?(options, :min)
|
|
124
|
+
lines << "Maximum length: #{literal(field(options, :max))}#{unit} (inclusive)." if option?(options, :max)
|
|
125
|
+
["<ul>#{lines.map { |line| "<li>#{line}</li>" }.join}</ul>", keys]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def option?(options, name)
|
|
129
|
+
options.has_key?(name.to_sym) || options.has_key?(name.to_s)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def field(options, name)
|
|
133
|
+
options.has_key?(name.to_sym) ? options[name.to_sym] : options[name.to_s]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def boolean?(value)
|
|
137
|
+
[true, false].include?(value)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def finite_number?(value)
|
|
141
|
+
value.is_a?(Integer) || (value.is_a?(Float) && value.finite?)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def json_value?(value)
|
|
145
|
+
case value
|
|
146
|
+
when String, Integer, true, false, nil then true
|
|
147
|
+
when Float then value.finite?
|
|
148
|
+
when Array then value.all? { |item| json_value?(item) }
|
|
149
|
+
when Hash then value.all? { |key, item| key.is_a?(String) && json_value?(item) }
|
|
150
|
+
else false
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def literal(value)
|
|
155
|
+
json_value?(value) ? "<code>#{html(JSON.generate(value))}</code>" : "<span>Ruby value: <code>#{html(value.inspect)}</code></span>"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def choice_literal(value)
|
|
159
|
+
value.is_a?(String) ? "<code>#{html(value)}</code>" : literal(value)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def value(value)
|
|
163
|
+
value.is_a?(String) ? html(value) : literal(value)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def data(value)
|
|
167
|
+
label, text = if json_value?(value)
|
|
168
|
+
['JSON', JSON.pretty_generate(value)]
|
|
169
|
+
else
|
|
170
|
+
['Ruby value', value.inspect]
|
|
171
|
+
end
|
|
172
|
+
"<p>#{label}:</p><pre><code>#{html(text)}</code></pre>"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def html(value)
|
|
176
|
+
CGI.escapeHTML(value.to_s)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|