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.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/Rakefile +1 -0
- data/doc/client-examples.md +96 -0
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/client_example.rb +47 -1
- data/lib/haveapi/client_examples/dynamic_helpers.rb +129 -0
- data/lib/haveapi/client_examples/go_client.rb +37 -0
- data/lib/haveapi/client_examples/js_async_client.rb +14 -0
- data/lib/haveapi/client_examples/js_client.rb +179 -135
- data/lib/haveapi/client_examples/python_async_client.rb +14 -0
- data/lib/haveapi/client_examples/python_client.rb +172 -0
- data/lib/haveapi/client_examples/rust_client.rb +59 -0
- data/lib/haveapi/client_examples/ts_async_client.rb +14 -0
- data/lib/haveapi/client_examples/ts_client.rb +14 -0
- data/lib/haveapi/documentation_clients.rb +126 -0
- data/lib/haveapi/documentation_context.rb +47 -0
- data/lib/haveapi/public/js/highlight-LICENSE +29 -0
- data/lib/haveapi/public/js/highlight.pack.js +2 -2
- data/lib/haveapi/public/js/highlighter.js +16 -6
- data/lib/haveapi/public/js/main.js +9 -1
- data/lib/haveapi/public/js/nojs-tabs.js +37 -3
- data/lib/haveapi/server.rb +34 -0
- data/lib/haveapi/version.rb +1 -1
- data/lib/haveapi/views/main_layout.erb +11 -0
- data/lib/haveapi/views/version_page/auth_body.erb +3 -3
- data/lib/haveapi/views/version_page/client_auth.erb +2 -2
- data/lib/haveapi/views/version_page/client_example.erb +2 -11
- data/lib/haveapi/views/version_page/client_init.erb +2 -2
- data/lib/haveapi/views/version_page/client_result.erb +5 -0
- data/lib/haveapi/views/version_page/resource_body.erb +3 -7
- data/lib/haveapi/views/version_page.erb +13 -9
- data/spec/client_examples/assets/highlighter_test.cjs +26 -0
- data/spec/client_examples/assets/tabs_test.cjs +54 -0
- data/spec/client_examples/context_spec.rb +68 -0
- data/spec/client_examples/dynamic_acceptance.md +83 -0
- data/spec/client_examples/dynamic_acceptance.rb +151 -0
- data/spec/client_examples/dynamic_acceptance_spec.rb +76 -0
- data/spec/client_examples/dynamic_fixture.rb +36 -0
- data/spec/client_examples/dynamic_js_spec.rb +195 -0
- data/spec/client_examples/dynamic_metadata_spec.rb +52 -0
- data/spec/client_examples/dynamic_mounted_paths_spec.rb +65 -0
- data/spec/client_examples/dynamic_python_spec.rb +123 -0
- data/spec/client_examples/go_spec.rb +57 -0
- data/spec/client_examples/profiles_spec.rb +95 -0
- data/spec/client_examples/rust_client_spec.rb +75 -0
- data/spec/client_examples/syntax_spec.rb +2 -1
- data/spec/documentation/auth_filtering_spec.rb +12 -0
- data/spec/documentation/client_examples_spec.rb +82 -0
- data/spec/packaging_spec.rb +13 -4
- data/test_support/dynamic_documentation_api.rb +171 -0
- data/test_support/dynamic_protocol_spec.rb +133 -0
- data/test_support/dynamic_python_login_spec.rb +75 -0
- metadata +34 -4
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4d308a5a5e69e262ce771a7ec707783c63af02c38726df08c8d5c2f68bae18d3
|
|
4
|
+
data.tar.gz: b70e5a101d631bae5e90d999f50d621643f511fef3a61a45b78f35369e0528c6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f3081d61476f4ddeb15b44028ff40a9073587520c13bf776f6e675ebcb41439d236c2f463029c969f589e6342e8ca13c550b3d6ab6c1a6dfb639b1bc6a99a940
|
|
7
|
+
data.tar.gz: 6523e8942f1fa7ef18c4fbdab85ea073b1e6df14249c948f270b573c3193de3c8d39046440ca132d9f4f30f9d94393392cedc9170310f419a2be8607bac07d0a
|
data/.gitignore
CHANGED
data/Rakefile
CHANGED
|
@@ -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.
|
|
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
|