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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/benchmarks/documentation.rb +29 -6
  4. data/benchmarks/endpoints.rb +153 -24
  5. data/benchmarks/spec/client_examples_spec.rb +109 -37
  6. data/doc/client-examples.md +218 -45
  7. data/doc/documentation-oauth.md +157 -0
  8. data/doc/documentation-ui.md +192 -0
  9. data/doc/index.md +3 -6
  10. data/doc/performance.md +39 -12
  11. data/haveapi.gemspec +1 -1
  12. data/lib/haveapi/authentication/chain.rb +19 -0
  13. data/lib/haveapi/authentication/oauth2/provider.rb +7 -1
  14. data/lib/haveapi/client_example.rb +143 -15
  15. data/lib/haveapi/client_examples/curl.rb +30 -20
  16. data/lib/haveapi/client_examples/dynamic_helpers.rb +1 -1
  17. data/lib/haveapi/client_examples/go_client.rb +9 -2
  18. data/lib/haveapi/client_examples/http.rb +9 -4
  19. data/lib/haveapi/client_examples/js_async_client.rb +7 -0
  20. data/lib/haveapi/client_examples/js_client.rb +47 -36
  21. data/lib/haveapi/client_examples/php_client.rb +161 -75
  22. data/lib/haveapi/client_examples/python_async_client.rb +3 -0
  23. data/lib/haveapi/client_examples/python_client.rb +61 -47
  24. data/lib/haveapi/client_examples/ruby_cli.rb +27 -18
  25. data/lib/haveapi/client_examples/ruby_client.rb +80 -31
  26. data/lib/haveapi/client_examples/rust_client.rb +20 -9
  27. data/lib/haveapi/client_examples/ts_async_client.rb +7 -0
  28. data/lib/haveapi/client_examples/ts_client.rb +3 -0
  29. data/lib/haveapi/client_examples/ts_generated_client.rb +10 -2
  30. data/lib/haveapi/documentation_context.rb +7 -2
  31. data/lib/haveapi/documentation_helpers.rb +223 -0
  32. data/lib/haveapi/documentation_oauth.rb +399 -0
  33. data/lib/haveapi/documentation_search.rb +225 -0
  34. data/lib/haveapi/documentation_search_index.rb +79 -0
  35. data/lib/haveapi/documentation_validators.rb +179 -0
  36. data/lib/haveapi/public/css/documentation.css +343 -0
  37. data/lib/haveapi/public/js/documentation.js +992 -0
  38. data/lib/haveapi/server.rb +144 -86
  39. data/lib/haveapi/shared_documentation_helpers.rb +54 -0
  40. data/lib/haveapi/version.rb +1 -1
  41. data/lib/haveapi/views/doc_layout.erb +16 -0
  42. data/lib/haveapi/views/doc_sidebars/create-client.erb +3 -3
  43. data/lib/haveapi/views/doc_sidebars/json-schema.erb +1 -1
  44. data/lib/haveapi/views/doc_sidebars/protocol.erb +5 -5
  45. data/lib/haveapi/views/documentation/search_form.erb +10 -0
  46. data/lib/haveapi/views/documentation/search_index.erb +1 -0
  47. data/lib/haveapi/views/documentation/search_results.erb +20 -0
  48. data/lib/haveapi/views/index.erb +2 -4
  49. data/lib/haveapi/views/main_layout.erb +12 -6
  50. data/lib/haveapi/views/shared_documentation.erb +12 -0
  51. data/lib/haveapi/views/shared_sidebar.erb +25 -0
  52. data/lib/haveapi/views/version_page/auth_body.erb +12 -33
  53. data/lib/haveapi/views/version_page/client_overview.erb +40 -0
  54. data/lib/haveapi/views/version_page/example_panels.erb +18 -0
  55. data/lib/haveapi/views/version_page/resource_body.erb +91 -132
  56. data/lib/haveapi/views/version_page/resource_directory.erb +14 -0
  57. data/lib/haveapi/views/version_page/usage.erb +7 -0
  58. data/lib/haveapi/views/version_page.erb +36 -117
  59. data/lib/haveapi/views/version_sidebar/auth_nav.erb +12 -16
  60. data/lib/haveapi/views/version_sidebar/auth_resource_nav.erb +19 -0
  61. data/lib/haveapi/views/version_sidebar/authentication_control.erb +26 -0
  62. data/lib/haveapi/views/version_sidebar/authentication_status.erb +3 -0
  63. data/lib/haveapi/views/version_sidebar/brand.erb +31 -0
  64. data/lib/haveapi/views/version_sidebar/resource_nav.erb +11 -20
  65. data/lib/haveapi/views/version_sidebar.erb +59 -48
  66. data/spec/authentication/basic_spec.rb +10 -10
  67. data/spec/authentication/oauth2_chain_spec.rb +72 -0
  68. data/spec/authentication/oauth2_token_validation_spec.rb +38 -0
  69. data/spec/client_examples/assets/documentation_test.cjs +817 -0
  70. data/spec/client_examples/context_performance_spec.rb +5 -4
  71. data/spec/client_examples/context_spec.rb +23 -2
  72. data/spec/client_examples/detail_spec.rb +175 -0
  73. data/spec/client_examples/dynamic_acceptance.md +59 -26
  74. data/spec/client_examples/dynamic_acceptance.rb +164 -44
  75. data/spec/client_examples/dynamic_acceptance_spec.rb +140 -23
  76. data/spec/client_examples/dynamic_fixture.rb +4 -3
  77. data/spec/client_examples/dynamic_js_spec.rb +20 -11
  78. data/spec/client_examples/dynamic_metadata_spec.rb +69 -0
  79. data/spec/client_examples/dynamic_mounted_paths_spec.rb +9 -7
  80. data/spec/client_examples/dynamic_python_spec.rb +62 -11
  81. data/spec/client_examples/go_spec.rb +14 -1
  82. data/spec/client_examples/presentation_spec.rb +247 -0
  83. data/spec/client_examples/profile_mount_spec.rb +40 -13
  84. data/spec/client_examples/rust_client_spec.rb +35 -1
  85. data/spec/client_examples/shell_spec.rb +268 -0
  86. data/spec/client_examples/syntax_spec.rb +192 -0
  87. data/spec/client_examples/ts_generated_client_spec.rb +15 -2
  88. data/spec/client_examples/typescript_profiles_spec.rb +1 -1
  89. data/spec/documentation/auth_filtering_spec.rb +9 -4
  90. data/spec/documentation/canonical_routes_spec.rb +89 -0
  91. data/spec/documentation/client_examples_spec.rb +39 -5
  92. data/spec/documentation/client_overview_spec.rb +145 -0
  93. data/spec/documentation/current_user_html_escaping_spec.rb +7 -2
  94. data/spec/documentation/example_variants_spec.rb +93 -0
  95. data/spec/documentation/fixtures/search_index.json +476 -0
  96. data/spec/documentation/identity_and_guides_spec.rb +130 -0
  97. data/spec/documentation/identity_context_spec.rb +180 -0
  98. data/spec/documentation/navigation_spec.rb +296 -0
  99. data/spec/documentation/oauth_fixture_spec.rb +39 -0
  100. data/spec/documentation/oauth_spec.rb +912 -0
  101. data/spec/documentation/oauth_transport_spec.rb +45 -0
  102. data/spec/documentation/performance_spec.rb +47 -6
  103. data/spec/documentation/reference_headings_spec.rb +190 -0
  104. data/spec/documentation/search_index_routes_spec.rb +202 -0
  105. data/spec/documentation/search_index_spec.rb +85 -0
  106. data/spec/documentation/search_routes_spec.rb +199 -0
  107. data/spec/documentation/search_spec.rb +134 -0
  108. data/spec/documentation/shared_pages_spec.rb +240 -0
  109. data/spec/documentation/validators_spec.rb +123 -0
  110. data/spec/documentation_spec.rb +3 -3
  111. data/spec/packaging_spec.rb +21 -2
  112. data/spec/performance/documentation_identity_fixture_spec.rb +47 -0
  113. data/spec/performance/documentation_shapes_spec.rb +97 -0
  114. data/spec/performance/json_activation_spec.rb +78 -1
  115. data/spec/performance/release_lineage_spec.rb +4 -3
  116. data/spec/performance/runner_spec.rb +1 -0
  117. data/test_support/all_client_performance_fixture.rb +6 -10
  118. data/test_support/documentation_oauth_fixture.rb +54 -0
  119. data/test_support/documentation_performance_fixture.rb +19 -1
  120. data/test_support/dynamic_documentation_api.rb +35 -10
  121. data/test_support/dynamic_python_login_spec.rb +3 -11
  122. 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