haveapi 0.29.9 → 0.30.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/Rakefile +1 -0
  4. data/doc/client-examples.md +96 -0
  5. data/haveapi.gemspec +1 -1
  6. data/lib/haveapi/client_example.rb +47 -1
  7. data/lib/haveapi/client_examples/dynamic_helpers.rb +129 -0
  8. data/lib/haveapi/client_examples/go_client.rb +37 -0
  9. data/lib/haveapi/client_examples/js_async_client.rb +14 -0
  10. data/lib/haveapi/client_examples/js_client.rb +179 -135
  11. data/lib/haveapi/client_examples/python_async_client.rb +14 -0
  12. data/lib/haveapi/client_examples/python_client.rb +172 -0
  13. data/lib/haveapi/client_examples/rust_client.rb +59 -0
  14. data/lib/haveapi/client_examples/ts_async_client.rb +14 -0
  15. data/lib/haveapi/client_examples/ts_client.rb +14 -0
  16. data/lib/haveapi/documentation_clients.rb +126 -0
  17. data/lib/haveapi/documentation_context.rb +47 -0
  18. data/lib/haveapi/public/js/highlight-LICENSE +29 -0
  19. data/lib/haveapi/public/js/highlight.pack.js +2 -2
  20. data/lib/haveapi/public/js/highlighter.js +16 -6
  21. data/lib/haveapi/public/js/main.js +9 -1
  22. data/lib/haveapi/public/js/nojs-tabs.js +37 -3
  23. data/lib/haveapi/server.rb +34 -0
  24. data/lib/haveapi/version.rb +1 -1
  25. data/lib/haveapi/views/main_layout.erb +11 -0
  26. data/lib/haveapi/views/version_page/auth_body.erb +3 -3
  27. data/lib/haveapi/views/version_page/client_auth.erb +2 -2
  28. data/lib/haveapi/views/version_page/client_example.erb +2 -11
  29. data/lib/haveapi/views/version_page/client_init.erb +2 -2
  30. data/lib/haveapi/views/version_page/client_result.erb +5 -0
  31. data/lib/haveapi/views/version_page/resource_body.erb +3 -7
  32. data/lib/haveapi/views/version_page.erb +13 -9
  33. data/spec/client_examples/assets/highlighter_test.cjs +26 -0
  34. data/spec/client_examples/assets/tabs_test.cjs +54 -0
  35. data/spec/client_examples/context_spec.rb +68 -0
  36. data/spec/client_examples/dynamic_acceptance.md +83 -0
  37. data/spec/client_examples/dynamic_acceptance.rb +151 -0
  38. data/spec/client_examples/dynamic_acceptance_spec.rb +76 -0
  39. data/spec/client_examples/dynamic_fixture.rb +36 -0
  40. data/spec/client_examples/dynamic_js_spec.rb +195 -0
  41. data/spec/client_examples/dynamic_metadata_spec.rb +52 -0
  42. data/spec/client_examples/dynamic_mounted_paths_spec.rb +65 -0
  43. data/spec/client_examples/dynamic_python_spec.rb +123 -0
  44. data/spec/client_examples/go_spec.rb +57 -0
  45. data/spec/client_examples/profiles_spec.rb +95 -0
  46. data/spec/client_examples/rust_client_spec.rb +75 -0
  47. data/spec/client_examples/syntax_spec.rb +2 -1
  48. data/spec/documentation/auth_filtering_spec.rb +12 -0
  49. data/spec/documentation/client_examples_spec.rb +82 -0
  50. data/spec/packaging_spec.rb +13 -4
  51. data/test_support/dynamic_documentation_api.rb +171 -0
  52. data/test_support/dynamic_protocol_spec.rb +133 -0
  53. data/test_support/dynamic_python_login_spec.rb +75 -0
  54. metadata +34 -4
  55. data/lib/haveapi/client_examples/fs_client.rb +0 -119
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d432ecb79a2fef09f812f5282c0d23425c31f58452d4da814b6fe2ef65ac6414
4
- data.tar.gz: abb6bc99854b4eddb61bd94fffaaf1db7dbaa51db8a4732801517f6ea620b001
3
+ metadata.gz: 4d308a5a5e69e262ce771a7ec707783c63af02c38726df08c8d5c2f68bae18d3
4
+ data.tar.gz: b70e5a101d631bae5e90d999f50d621643f511fef3a61a45b78f35369e0528c6
5
5
  SHA512:
6
- metadata.gz: bde834ad60060ad0e61b21070518c4f3ba7252684d5bd2d595b256b0c15f84d70d4a25efbd30666d7bb2e69b162ab6e8ff2b3ad420b8574bb275d7453f339659
7
- data.tar.gz: bf305ce254ba7824812dd8755bc2f061b54ebc5b6a74d40cf0e91976048f6d257c34b4b6098ff49e6ad331b8aae612f91e521aea39af5f22f83abfb4eebbec11
6
+ metadata.gz: f3081d61476f4ddeb15b44028ff40a9073587520c13bf776f6e675ebcb41439d236c2f463029c969f589e6342e8ca13c550b3d6ab6c1a6dfb639b1bc6a99a940
7
+ data.tar.gz: 6523e8942f1fa7ef18c4fbdab85ea073b1e6df14249c948f270b573c3193de3c8d39046440ca132d9f4f30f9d94393392cedc9170310f419a2be8607bac07d0a
data/.gitignore CHANGED
@@ -1,4 +1,5 @@
1
1
  doc/protocol.*
2
2
  doc/create-client.md
3
3
  doc/typed-input-validation.md
4
+ doc/client-examples.md
4
5
  doc/json-schema.html
data/Rakefile CHANGED
@@ -13,6 +13,7 @@ task :check_doc do
13
13
  doc/protocol.md
14
14
  doc/create-client.md
15
15
  doc/typed-input-validation.md
16
+ doc/client-examples.md
16
17
  doc/protocol.png
17
18
  ]
18
19
  missing = required.reject { |path| File.file?(path) && File.size?(path) }
@@ -0,0 +1,96 @@
1
+ # Client examples in API documentation
2
+
3
+ The Ruby server renders examples from the current user's API description.
4
+ JavaScript and TypeScript examples cover callback and async clients; Python
5
+ examples cover sync and async clients. These clients need no server configuration.
6
+ Ruby, PHP, CLI, curl and HTTP examples are also available. A sample
7
+ that a client cannot represent shows a limitation instead of dropping input or
8
+ inventing a successful result.
9
+
10
+ Go and Rust tabs require a profile for a generated SDK. Their package versions
11
+ are independent of HaveAPI and the API version. Without a profile, the server
12
+ hides those clients from the overview and all initialization, authentication and
13
+ action panels. Install the matching generator gem only when configuring its
14
+ profile; ordinary servers do not depend on either generator.
15
+
16
+ ## Configure a generated SDK
17
+
18
+ Register profiles before mounting the server. Paths must name trusted absolute
19
+ local files. Keep the SDK's saved description and generator configuration;
20
+ the server does not fetch descriptions or regenerate SDKs while serving docs.
21
+
22
+ ```ruby
23
+ api.documentation_clients('1.0',
24
+ go: {
25
+ import_path: 'example.org/widget/client',
26
+ module_version: 'v1.2.3',
27
+ description_path: '/srv/widget-sdk/api-1.0.json',
28
+ generator_version: '0.30.0'
29
+ },
30
+ rust: {
31
+ snapshot_path: '/srv/widget-sdk/api-1.0.snapshot.json',
32
+ generator_config_path: '/srv/widget-sdk/generator.toml',
33
+ generator_version: '0.30.0'
34
+ }
35
+ )
36
+ api.mount('/api/')
37
+ ```
38
+
39
+ Use the SDK's published package name and version. The Rust crate name and
40
+ version come from the generator TOML configuration, which also records the
41
+ formatter and language requirements. Go uses the import alias `client`. The
42
+ installed generator version must match the profile. Missing files or adapters,
43
+ unknown fields, duplicate profiles and invalid SDK schemas or configurations
44
+ cause startup to fail. Mounting also rejects profiles for unselected API
45
+ versions and Rust snapshots bound to another API version.
46
+
47
+ An SDK profile can still serve examples after unrelated additions to the live
48
+ API. Each example is checked against the SDK's saved action, route, layout and
49
+ parameter contract. An unsupported or stale sample produces a limitation.
50
+ Profiles do not expose actions, fields, defaults or samples hidden from the
51
+ current user. The full SDK schema and local paths stay private; request
52
+ contexts use only the current user's filtered description and the selected
53
+ sample.
54
+
55
+ ## Renderer extensions
56
+
57
+ Existing renderer positional constructors and `init`, `auth(method, desc)` and
58
+ `example(sample)` or `request(sample)`/`response(sample)` methods still work.
59
+ The registry binds a request-local `HaveAPI::ClientExample::Context` through
60
+ `with_context`. It holds the selected version, URLs including the API mount,
61
+ the filtered description, and the current resource, action and sample.
62
+ `context.with(...)` creates another frozen copy without freezing the server's
63
+ shared description.
64
+
65
+ Renderers may return a String or nil through their existing methods. New
66
+ renderers return either `{status: :code, code: source}` or
67
+ `{status: :limitation, reason: text}`. Source and limitation text are plain
68
+ strings, escaped once by the templates. A stable class `id` identifies a
69
+ variant; `code` selects its syntax language. `available?(context)` controls the
70
+ overview and every panel before rendering.
71
+
72
+ Generated-client adapters belong to their generator gems and consume context
73
+ readers without depending on the server gem. They resolve names using the
74
+ saved generator schema and never run a compiler, formatter or generator in an
75
+ HTML request. Keep adapter rendering read-only: configured adapters are frozen
76
+ and shared, while filtered contexts are copied per request.
77
+
78
+ ## Verification
79
+
80
+ `make test-documentation` checks renderers, contexts, profiles, rendered HTML and
81
+ local assets. Prepare the component bundles and JavaScript development
82
+ dependencies with `make release-deps` first. The target builds the JavaScript
83
+ package before compiling TypeScript examples against its public declarations;
84
+ the Ruby server CI uses the same order.
85
+
86
+ `make test-documentation-python` additionally checks the Python renderer limits
87
+ against the real request encoder. It requires the locked Python environment
88
+ prepared by `make release-deps` and is included in `make test`. The ordinary
89
+ Ruby renderer checks run without the Python package environment.
90
+
91
+ Each client also has acceptance commands that check executable snippets against
92
+ installed packages or a generated SDK. These use
93
+ rendered source against disposable fixtures; syntax checks alone do not
94
+ establish that client calls or authentication work. See each client's
95
+ development documentation for its test environment. Highlighter rebuilding is
96
+ described in [utils/highlight](../utils/highlight/README.md).
data/haveapi.gemspec CHANGED
@@ -15,7 +15,7 @@ Gem::Specification.new do |s|
15
15
  s.required_ruby_version = ">= #{File.read('../../.ruby-version').strip}"
16
16
 
17
17
  s.add_dependency 'activesupport', '>= 7.1'
18
- s.add_dependency 'haveapi-client', '~> 0.29.9'
18
+ s.add_dependency 'haveapi-client', '~> 0.30.0'
19
19
  s.add_dependency 'i18n', '>= 1.6', '< 2'
20
20
  s.add_dependency 'json'
21
21
  s.add_dependency 'mail'
@@ -33,6 +33,47 @@ module HaveAPI
33
33
  @order
34
34
  end
35
35
 
36
+ def id(value = nil)
37
+ @id = value.to_s.freeze unless value.nil?
38
+ return @id if @id
39
+
40
+ # Preserve the anchors of the retained built-in formats.
41
+ { 'RubyClient' => '0', 'PhpClient' => '2', 'RubyCli' => '3', 'Curl' => '5', 'Http' => '6' }[name.to_s.split('::').last] ||
42
+ name.to_s.gsub('::', '-').downcase
43
+ end
44
+
45
+ def available?(_context)
46
+ true
47
+ end
48
+
49
+ def render(kind, context:, auth_method: nil, auth_description: nil)
50
+ arguments = [context.host, context.base_url, context.version]
51
+ arguments.push(context.resource_path, context.resource, context.action_name, context.action) if kind == :example
52
+ renderer = new(*arguments)
53
+ renderer.with_context(context)
54
+ result = case kind
55
+ when :init then renderer.init
56
+ when :auth then renderer.auth(auth_method, auth_description)
57
+ when :example
58
+ if renderer.respond_to?(:example)
59
+ renderer.example(context.sample)
60
+ else
61
+ [renderer.request(context.sample), renderer.response(context.sample)].compact.join("\n\n")
62
+ end
63
+ else raise ArgumentError, "Unknown client example kind: #{kind}"
64
+ end
65
+ return if result.nil?
66
+ return { status: :code, code: result.dup.freeze }.freeze if result.is_a?(String)
67
+
68
+ if result.is_a?(Hash) && result[:status] == :code && result[:code].is_a?(String)
69
+ { status: :code, code: result[:code].dup.freeze }.freeze
70
+ elsif result.is_a?(Hash) && result[:status] == :limitation && result[:reason].is_a?(String)
71
+ { status: :limitation, reason: result[:reason].dup.freeze }.freeze
72
+ else
73
+ raise ArgumentError, 'Invalid client example result'
74
+ end
75
+ end
76
+
36
77
  def register(klass)
37
78
  @clients ||= []
38
79
  @clients << klass
@@ -59,7 +100,7 @@ module HaveAPI
59
100
  end
60
101
  end
61
102
 
62
- attr_reader :resource_path, :resource, :action_name, :action, :host, :base_url, :version
103
+ attr_reader :resource_path, :resource, :action_name, :action, :host, :base_url, :version, :context
63
104
 
64
105
  def initialize(host, base_url, version, *args)
65
106
  @host = host
@@ -72,6 +113,11 @@ module HaveAPI
72
113
 
73
114
  def auth(method, desc); end
74
115
 
116
+ def with_context(value)
117
+ @context = value
118
+ self
119
+ end
120
+
75
121
  def version_url
76
122
  File.join(base_url, "v#{version}", '/')
77
123
  end
@@ -0,0 +1,129 @@
1
+ require 'json'
2
+ require 'haveapi/client_example'
3
+
4
+ module HaveAPI::ClientExamples
5
+ module DynamicHelpers
6
+ class Unsupported < StandardError; end
7
+
8
+ def field(value, name, default = nil)
9
+ return default unless value.is_a?(Hash)
10
+
11
+ value.fetch(name) { value.fetch(name.to_s, default) }
12
+ end
13
+
14
+ def code_result
15
+ { status: :code, code: yield.freeze }.freeze
16
+ rescue Unsupported, JSON::GeneratorError => e
17
+ { status: :limitation, reason: e.message.freeze }.freeze
18
+ end
19
+
20
+ def limitation(reason)
21
+ { status: :limitation, reason: reason.freeze }.freeze
22
+ end
23
+
24
+ def api_base
25
+ context ? context.base_url : base_url
26
+ end
27
+
28
+ def api_version
29
+ context ? context.version : version.to_s
30
+ end
31
+
32
+ def current_action
33
+ context&.action || action
34
+ end
35
+
36
+ def current_resource_path
37
+ context&.resource_path || resource_path
38
+ end
39
+
40
+ def current_action_name
41
+ context&.action_name || action_name
42
+ end
43
+
44
+ def json_data(value)
45
+ case value
46
+ when Hash
47
+ result = {}
48
+ value.each do |key, entry|
49
+ unless key.is_a?(String) || key.is_a?(Symbol)
50
+ raise Unsupported, 'This sample has a non-string object key.'
51
+ end
52
+ raise Unsupported, 'This sample has duplicate serialized object keys.' if result.has_key?(key.to_s)
53
+
54
+ result[key.to_s] = json_data(entry)
55
+ end
56
+ result
57
+ when Array
58
+ value.map { |entry| json_data(entry) }
59
+ when String, Integer, TrueClass, FalseClass, NilClass
60
+ value
61
+ when Float
62
+ raise Unsupported, 'This sample has a non-finite number.' unless value.finite?
63
+
64
+ value
65
+ else
66
+ raise Unsupported, 'This sample contains a value without a supported literal representation.'
67
+ end
68
+ end
69
+
70
+ def structured_sample(sample)
71
+ unless sample.is_a?(Hash) && (!field(sample, :request).nil? || !field(sample, :response).nil?)
72
+ raise Unsupported, 'This example contains prose only; no executable request is supplied.'
73
+ end
74
+
75
+ %i[global object].each do |scope|
76
+ next if scope == :object && field(current_action, :input).nil?
77
+
78
+ input = field(field(field(current_action, :meta), scope), :input)
79
+ if field(input, :parameters, {}).values.any? { |parameter| field(parameter, :required) }
80
+ raise Unsupported, 'This action requires input metadata that the sample format cannot supply.'
81
+ end
82
+ end
83
+
84
+ placeholders = field(current_action, :path, '').scan(/\{([a-zA-Z0-9_-]+)\}/).flatten
85
+ values = field(sample, :path_params, []) || []
86
+ unless values.is_a?(Array) && values.length == placeholders.length
87
+ raise Unsupported, 'The sample does not supply every action path argument.'
88
+ end
89
+ unless values.all? { |value| value.is_a?(String) || value.is_a?(Integer) }
90
+ raise Unsupported, 'Action path arguments must be strings or integers.'
91
+ end
92
+
93
+ request = field(sample, :request)
94
+ input = field(current_action, :input)
95
+ if !request.nil? && input.nil? && request != {}
96
+ raise Unsupported, 'The sample supplies input for an action without an input description.'
97
+ end
98
+
99
+ if request.is_a?(Hash) && input
100
+ known = field(input, :parameters, {}).keys.map(&:to_s)
101
+ unless (request.keys.map(&:to_s) - known).empty?
102
+ raise Unsupported, 'The sample contains input parameters absent from the visible description.'
103
+ end
104
+ end
105
+ [placeholders.zip(values).to_h, json_data(request)]
106
+ end
107
+
108
+ def supported_auth?(method)
109
+ %i[basic token oauth2].include?(method.to_sym) &&
110
+ (!context || field(context.description, :authentication, {}).keys.map(&:to_s).include?(method.to_s))
111
+ end
112
+
113
+ def token_request_fields(description)
114
+ token = field(field(description, :resources, {}), :token, {})
115
+ request = field(field(token, :actions, {}), :request, {})
116
+ field(field(request, :input, {}), :parameters, {})
117
+ end
118
+
119
+ def token_login_fields(description)
120
+ token_request_fields(description).reject do |name, _|
121
+ %w[token lifetime interval].include?(name.to_s)
122
+ end
123
+ end
124
+
125
+ def indent(source, spaces = 2)
126
+ source.lines.map { |line| line.strip.empty? ? line : "#{' ' * spaces}#{line}" }.join
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,37 @@
1
+ require 'haveapi/client_example'
2
+
3
+ module HaveAPI::ClientExamples
4
+ class GoClient < HaveAPI::ClientExample
5
+ label 'Go'
6
+ id 'go'
7
+ code :go
8
+ order 16
9
+
10
+ def self.available?(context)
11
+ context.profile && context.profile.language == :go
12
+ end
13
+
14
+ def init
15
+ render_documentation(:init)
16
+ end
17
+
18
+ def auth(method, desc)
19
+ render_documentation(:auth, auth_method: method, auth_description: desc)
20
+ end
21
+
22
+ def example(sample)
23
+ render_documentation(:example, sample:)
24
+ end
25
+
26
+ protected
27
+
28
+ def render_documentation(kind, sample: nil, **)
29
+ unless context && self.class.available?(context)
30
+ return { status: :limitation, reason: 'A Go SDK profile is required.' }.freeze
31
+ end
32
+
33
+ selected = kind == :example ? context.with(sample:) : context
34
+ context.profile.adapter.render(kind:, context: selected, mode: :sync, **)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,14 @@
1
+ require 'haveapi/client_examples/js_client'
2
+
3
+ module HaveAPI::ClientExamples
4
+ class JsAsyncClient < JsClient
5
+ label 'JavaScript (async)'
6
+ id 'javascript-async'
7
+ code :javascript
8
+ order 11
9
+
10
+ def asynchronous?
11
+ true
12
+ end
13
+ end
14
+ end