haveapi 0.30.0 → 0.30.1
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/doc/client-examples.md +123 -21
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/documentation_clients.rb +158 -35
- data/lib/haveapi/documentation_schema.rb +73 -0
- data/lib/haveapi/server.rb +5 -0
- data/lib/haveapi/version.rb +1 -1
- data/lib/haveapi/views/version_page.erb +2 -2
- data/spec/client_examples/dynamic_js_spec.rb +1 -0
- data/spec/client_examples/profile_mount_spec.rb +215 -0
- data/spec/client_examples/profiles_spec.rb +202 -32
- data/spec/client_examples/structural_schema_spec.rb +48 -0
- data/spec/documentation/client_examples_spec.rb +10 -0
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3d58d9e427d490d025fec95d60739ea4aa17b4e86cc3fb9978b7c00b751e6e75
|
|
4
|
+
data.tar.gz: e0b958ac9924d4cacaf75513d79690c3ed2519d6b5d1522a0433d92285742887
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 78980868da2569146dccf39ce14309e27b97d51b4a266b7709813faafb191577520f31408dd6ebf1e5310b37bd43197e9b4e838503cd60c5d8b480607e8c5f6d
|
|
7
|
+
data.tar.gz: 5a2a6ad3e686e3d77b86fb83e7e27b5eaf5e2ac573013fa938255b203405fdabe899a605f71cea8088769e81f323b6678be00272889c56d629757356d9170d87
|
data/doc/client-examples.md
CHANGED
|
@@ -15,9 +15,96 @@ profile; ordinary servers do not depend on either generator.
|
|
|
15
15
|
|
|
16
16
|
## Configure a generated SDK
|
|
17
17
|
|
|
18
|
-
Register profiles before mounting the server.
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
Register profiles before mounting the server. For an SDK that matches the current
|
|
19
|
+
API, configure its package identity:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
api.documentation_clients('1.0',
|
|
23
|
+
go: {
|
|
24
|
+
import_path: 'example.org/widget/client'
|
|
25
|
+
},
|
|
26
|
+
rust: {
|
|
27
|
+
crate_name: 'widget-client'
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
api.mount('/api/')
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`generator_version` defaults to the server's `HaveAPI::VERSION`. Install the
|
|
34
|
+
optional `haveapi-go-client` or `haveapi-rust-client` generator gem at that
|
|
35
|
+
version. An explicit `generator_version` requires the installed generator to
|
|
36
|
+
report that version; it never downloads one. It is independent of the SDK and
|
|
37
|
+
API versions.
|
|
38
|
+
|
|
39
|
+
At the end of mount, the server builds one private structural model per configured
|
|
40
|
+
API version. The model includes authentication resources, routes and changes from
|
|
41
|
+
post-mount hooks, so canonical names and parent arguments use the complete API
|
|
42
|
+
declaration. The private copy excludes examples, prose and parameter defaults.
|
|
43
|
+
HTML requests use the current user's authorized description, fields and samples;
|
|
44
|
+
they never expose the private model or its initialization diagnostics. Without
|
|
45
|
+
a configured profile, the server loads no optional generator and shows no Go or
|
|
46
|
+
Rust panels.
|
|
47
|
+
|
|
48
|
+
Automatic profiles assume that the installed SDK matches the current server's
|
|
49
|
+
generation contract. A package name or version does not prove its contents.
|
|
50
|
+
An older SDK can lack new actions or use different names after a collision or
|
|
51
|
+
route change. Check actual SDK compatibility before advertising it; use a saved
|
|
52
|
+
schema override below when the SDK represents an older or restricted API.
|
|
53
|
+
|
|
54
|
+
### Optional SDK versions and sources
|
|
55
|
+
|
|
56
|
+
Set `module_version: 'v1.2.3'` for Go or `crate_version: '1.2.3'` for Rust when a
|
|
57
|
+
specific package version is needed. Omit the field or use nil to let the user's
|
|
58
|
+
package manager select a release. The server does not query a registry or claim
|
|
59
|
+
to have resolved a version. Omitted versions produce these installation hints:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
go get example.org/widget/client@latest
|
|
63
|
+
cargo add widget-client
|
|
64
|
+
# Blocking Rust variant:
|
|
65
|
+
cargo add widget-client --features blocking
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Go resolves `@latest` by its normal rules. Cargo selects a compatible release
|
|
69
|
+
according to its resolver, manifest and toolchain; existing lockfiles still
|
|
70
|
+
apply. Explicit Cargo versions remain version requirements, without an added
|
|
71
|
+
exact `=` pin. Latest selection can change over time; supply a version to control
|
|
72
|
+
the selection. Cargo never receives a literal `version = "latest"`. Rust
|
|
73
|
+
examples also show their Tokio and serde_json requirements.
|
|
74
|
+
|
|
75
|
+
For an unpublished Rust SDK, supply an HTTPS Git URL and a full commit SHA:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
api.documentation_clients('7.0',
|
|
79
|
+
rust: {
|
|
80
|
+
crate_name: 'vpsadmin-client',
|
|
81
|
+
source: {
|
|
82
|
+
git: 'https://github.com/vpsfreecz/vpsadmin-rust-client',
|
|
83
|
+
rev: 'ff876abe17e3301da62c1b6afbda055ec89db9d2'
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The dependency hint uses that source instead of a registry release:
|
|
90
|
+
|
|
91
|
+
```toml
|
|
92
|
+
# Cargo.toml
|
|
93
|
+
vpsadmin-client = { git = "https://github.com/vpsfreecz/vpsadmin-rust-client", rev = "ff876abe17e3301da62c1b6afbda055ec89db9d2" }
|
|
94
|
+
# The blocking variant also sets features = ["blocking"].
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`source` accepts only `git` and `rev`. URLs must be absolute HTTPS without
|
|
98
|
+
credentials, query strings, fragments or control characters; revisions must be
|
|
99
|
+
40 hexadecimal characters. Branches, tags, local paths and custom registries are
|
|
100
|
+
not supported. An explicit crate version additionally constrains the selected
|
|
101
|
+
source package. The server never fetches the repository or runs these user
|
|
102
|
+
installation commands. These settings do not regenerate or publish an SDK and
|
|
103
|
+
do not establish that this particular SDK matches a given API surface.
|
|
104
|
+
|
|
105
|
+
### Saved-schema overrides
|
|
106
|
+
|
|
107
|
+
The existing file-based profiles remain supported:
|
|
21
108
|
|
|
22
109
|
```ruby
|
|
23
110
|
api.documentation_clients('1.0',
|
|
@@ -33,24 +120,39 @@ api.documentation_clients('1.0',
|
|
|
33
120
|
generator_version: '0.30.0'
|
|
34
121
|
}
|
|
35
122
|
)
|
|
36
|
-
api.mount('/api/')
|
|
37
123
|
```
|
|
38
124
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
125
|
+
Paths name trusted absolute local files. `description_path` is Go's saved
|
|
126
|
+
machine-readable description, not HTML. `snapshot_path` is Rust's saved schema
|
|
127
|
+
and capture metadata. They select an exact SDK shape in place of the current
|
|
128
|
+
server declaration. Keep the files private and retain them with the SDK release.
|
|
129
|
+
|
|
130
|
+
Rust's legacy `generator_config_path` supplies the crate name/version and full
|
|
131
|
+
packaging configuration. It is mutually exclusive with inline `crate_name` and
|
|
132
|
+
`crate_version`, and can be paired with either a saved snapshot or automatic
|
|
133
|
+
schema. A saved snapshot can instead use inline crate identity. `source` works
|
|
134
|
+
with either identity form. The server validates the full legacy TOML; normal
|
|
135
|
+
inline documentation needs no license, compiler or formatter settings.
|
|
136
|
+
|
|
137
|
+
Saved files are read and validated during registration. Automatic models are
|
|
138
|
+
initialized at the end of mount. Missing files or generators, actual generator
|
|
139
|
+
version mismatches, unknown fields, conflicting identities and invalid schemas
|
|
140
|
+
fail startup. Mounting also rejects unavailable API versions and mismatched Rust
|
|
141
|
+
snapshot versions. Failed mounting publishes no profiles and leaves no usable
|
|
142
|
+
server application. An older explicitly pinned generator can serve a suitable
|
|
143
|
+
legacy file profile, but automatic/inline Rust mode requires the new factory
|
|
144
|
+
interface and never guesses a fallback.
|
|
145
|
+
|
|
146
|
+
A saved SDK can still serve examples after unrelated live additions. Each sample
|
|
147
|
+
must match its saved route, layout and supplied parameter contracts; unavailable
|
|
148
|
+
or stale samples show a limitation. Hidden actions, fields, defaults and samples
|
|
149
|
+
remain hidden from the current user. The server never fetches descriptions,
|
|
150
|
+
regenerates packages or runs a compiler/formatter during an HTML request.
|
|
151
|
+
|
|
152
|
+
Old profile settings work on the new server. Inline settings require the new
|
|
153
|
+
server code; rolling back that code also requires restoring compatible settings.
|
|
154
|
+
Mixed server workers can show different documentation examples without changing
|
|
155
|
+
wire requests or persisted data.
|
|
54
156
|
|
|
55
157
|
## Renderer extensions
|
|
56
158
|
|
|
@@ -71,8 +173,8 @@ overview and every panel before rendering.
|
|
|
71
173
|
|
|
72
174
|
Generated-client adapters belong to their generator gems and consume context
|
|
73
175
|
readers without depending on the server gem. They resolve names using the
|
|
74
|
-
|
|
75
|
-
HTML request. Keep adapter rendering read-only: configured adapters are frozen
|
|
176
|
+
complete private generator model and never run a compiler, formatter or generator
|
|
177
|
+
in an HTML request. Keep adapter rendering read-only: configured adapters are frozen
|
|
76
178
|
and shared, while filtered contexts are copied per request.
|
|
77
179
|
|
|
78
180
|
## Verification
|
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.30.
|
|
18
|
+
s.add_dependency 'haveapi-client', '~> 0.30.1'
|
|
19
19
|
s.add_dependency 'i18n', '>= 1.6', '< 2'
|
|
20
20
|
s.add_dependency 'json'
|
|
21
21
|
s.add_dependency 'mail'
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require 'json'
|
|
2
2
|
require 'haveapi/documentation_context'
|
|
3
|
+
require 'haveapi/documentation_schema'
|
|
3
4
|
|
|
4
5
|
module HaveAPI
|
|
5
6
|
class ClientExample
|
|
@@ -9,7 +10,7 @@ module HaveAPI
|
|
|
9
10
|
def initialize(language:, package_name:, package_version:, adapter:)
|
|
10
11
|
@language = language
|
|
11
12
|
@package_name = package_name.dup.freeze
|
|
12
|
-
@package_version = package_version
|
|
13
|
+
@package_version = package_version&.dup&.freeze
|
|
13
14
|
@adapter = adapter.freeze
|
|
14
15
|
freeze
|
|
15
16
|
end
|
|
@@ -20,64 +21,116 @@ module HaveAPI
|
|
|
20
21
|
|
|
21
22
|
KEYS = {
|
|
22
23
|
go: %i[import_path module_version description_path generator_version],
|
|
23
|
-
rust: %i[snapshot_path generator_config_path generator_version]
|
|
24
|
+
rust: %i[crate_name crate_version source snapshot_path generator_config_path generator_version]
|
|
24
25
|
}.freeze
|
|
25
26
|
|
|
26
27
|
def initialize
|
|
27
|
-
@
|
|
28
|
-
@
|
|
28
|
+
@registrations = {}
|
|
29
|
+
@profiles = {}.freeze
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
def register(version, **languages)
|
|
32
|
-
raise Invalid, 'Documentation client profiles are already mounted' if @
|
|
33
|
+
raise Invalid, 'Documentation client profiles are already mounted' if @closed
|
|
33
34
|
raise Invalid, 'Unknown documentation client language' unless (languages.keys - KEYS.keys).empty?
|
|
34
35
|
|
|
35
|
-
selected = @
|
|
36
|
+
selected = @registrations.fetch(version.to_s, {})
|
|
37
|
+
additions = {}
|
|
36
38
|
languages.each do |language, config|
|
|
37
39
|
next if config.nil?
|
|
38
40
|
raise Invalid, 'Duplicate documentation client profile' if selected.has_key?(language)
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
additions[language] = load_profile(language, config)
|
|
41
43
|
end
|
|
44
|
+
@registrations[version.to_s] = selected.merge(additions)
|
|
42
45
|
end
|
|
43
46
|
|
|
44
47
|
def mount(versions)
|
|
45
|
-
|
|
48
|
+
raise Invalid, 'Documentation client profiles are already mounted' if @closed
|
|
49
|
+
|
|
50
|
+
@closed = true
|
|
51
|
+
unknown = @registrations.keys - Array(versions).map(&:to_s)
|
|
46
52
|
raise Invalid, 'Documentation client profile selects an unavailable API version' unless unknown.empty?
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
|
|
54
|
+
@registrations.each do |version, languages|
|
|
55
|
+
snapshot = languages.dig(:rust, :snapshot)
|
|
56
|
+
if snapshot && snapshot.fetch('api_version').to_s != version
|
|
57
|
+
raise Invalid, 'Documentation Rust snapshot API version mismatch'
|
|
58
|
+
end
|
|
49
59
|
end
|
|
60
|
+
end
|
|
50
61
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
@
|
|
62
|
+
# Publish only after every adapter succeeds, using the complete mounted DSL.
|
|
63
|
+
def finalize(server)
|
|
64
|
+
raise Invalid, 'Documentation client profiles are not ready to finalize' unless @closed && !@finalized
|
|
65
|
+
|
|
66
|
+
completed = @registrations.to_h do |version, languages|
|
|
67
|
+
description = if languages.any? { |_language, config| !config[:profile] }
|
|
68
|
+
safely do
|
|
69
|
+
context = HaveAPI::Context.new(
|
|
70
|
+
server, version: server.versions.find { |v| v.to_s == version }, user: nil, endpoint: false, doc: true
|
|
71
|
+
)
|
|
72
|
+
StructuralSchema.copy(server.describe_version(context))
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
profiles = languages.to_h do |language, config|
|
|
76
|
+
profile = config[:profile] || safely { build_profile(language, config, description, server, version) }
|
|
77
|
+
[language, profile]
|
|
78
|
+
end
|
|
79
|
+
[version, profiles.freeze]
|
|
80
|
+
end
|
|
81
|
+
@profiles = completed.freeze
|
|
82
|
+
@registrations = nil
|
|
83
|
+
@finalized = true
|
|
84
|
+
rescue StandardError
|
|
85
|
+
abort_mount
|
|
86
|
+
raise
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def abort_mount
|
|
90
|
+
@profiles = {}.freeze
|
|
91
|
+
@registrations = nil
|
|
92
|
+
@closed = true
|
|
93
|
+
@finalized = true
|
|
54
94
|
end
|
|
55
95
|
|
|
56
96
|
def for_version(version)
|
|
57
|
-
@
|
|
97
|
+
@profiles.fetch(version.to_s, {}).dup.freeze
|
|
58
98
|
end
|
|
59
99
|
|
|
60
100
|
private
|
|
61
101
|
|
|
62
102
|
def validate_config(language, config)
|
|
63
|
-
unless config.is_a?(Hash) && config.keys
|
|
64
|
-
config.
|
|
103
|
+
unless config.is_a?(Hash) && (config.keys - KEYS.fetch(language)).empty? &&
|
|
104
|
+
config.all? { |key, value| %i[source module_version crate_version].include?(key) || nonempty_string?(value) }
|
|
65
105
|
raise Invalid, 'Invalid documentation client profile fields'
|
|
66
106
|
end
|
|
67
|
-
|
|
107
|
+
|
|
108
|
+
config = config.dup
|
|
109
|
+
config[:generator_version] = config.fetch(:generator_version, HaveAPI::VERSION)
|
|
110
|
+
return config if config[:generator_version].match?(/\A\d+\.\d+\.\d+(?:[-+][\w.-]+)?\z/)
|
|
68
111
|
|
|
69
112
|
raise Invalid, 'Invalid documentation generator version'
|
|
70
113
|
end
|
|
71
114
|
|
|
115
|
+
def nonempty_string?(value)
|
|
116
|
+
value.is_a?(String) && !value.strip.empty?
|
|
117
|
+
end
|
|
118
|
+
|
|
72
119
|
def local_file(path)
|
|
73
120
|
raise Invalid, 'Documentation profiles require absolute local files' unless path.start_with?('/') && File.file?(path)
|
|
74
121
|
|
|
75
122
|
path
|
|
76
123
|
end
|
|
77
124
|
|
|
78
|
-
def load_profile(language, config
|
|
79
|
-
|
|
80
|
-
|
|
125
|
+
def load_profile(language, config)
|
|
126
|
+
safely do
|
|
127
|
+
config = validate_config(language, config)
|
|
128
|
+
language == :go ? load_go(config) : load_rust(config)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def safely
|
|
133
|
+
yield
|
|
81
134
|
rescue LoadError
|
|
82
135
|
raise Invalid, 'Configured documentation client generator is not installed'
|
|
83
136
|
rescue JSON::ParserError, SystemCallError
|
|
@@ -89,11 +142,11 @@ module HaveAPI
|
|
|
89
142
|
end
|
|
90
143
|
|
|
91
144
|
def load_go(config)
|
|
92
|
-
name = config
|
|
93
|
-
version = config
|
|
94
|
-
unless name.match?(%r{\A[a-zA-Z0-9][a-zA-Z0-9._~/-]*\z}) &&
|
|
145
|
+
name = config[:import_path]
|
|
146
|
+
version = config[:module_version]
|
|
147
|
+
unless nonempty_string?(name) && name.match?(%r{\A[a-zA-Z0-9][a-zA-Z0-9._~/-]*\z}) &&
|
|
95
148
|
name.split('/').none? { |part| part.empty? || %w[. ..].include?(part) } &&
|
|
96
|
-
version.match?(/\Av\d+\.\d+\.\d+(?:[-+][\w.-]+)?\z/)
|
|
149
|
+
(version.nil? || (version.is_a?(String) && version.match?(/\Av\d+\.\d+\.\d+(?:[-+][\w.-]+)?\z/)))
|
|
97
150
|
raise Invalid, 'Invalid Go documentation client package'
|
|
98
151
|
end
|
|
99
152
|
|
|
@@ -102,24 +155,94 @@ module HaveAPI
|
|
|
102
155
|
raise Invalid, 'Documentation Go generator version mismatch'
|
|
103
156
|
end
|
|
104
157
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
158
|
+
prepared = { name: name.dup.freeze, version: version&.dup&.freeze }.freeze
|
|
159
|
+
return prepared unless config.has_key?(:description_path)
|
|
160
|
+
|
|
161
|
+
description = JSON.parse(File.read(local_file(config[:description_path])))
|
|
162
|
+
{ profile: go_profile(prepared, description) }.freeze
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def go_profile(config, description)
|
|
166
|
+
adapter = HaveAPI::GoClient::Documentation.new(description:, import_path: config[:name], module_version: config[:version])
|
|
167
|
+
profile(:go, config, adapter)
|
|
108
168
|
end
|
|
109
169
|
|
|
110
|
-
def load_rust(config
|
|
170
|
+
def load_rust(config)
|
|
111
171
|
require 'haveapi/rust_client/documentation'
|
|
112
172
|
unless config.fetch(:generator_version) == HaveAPI::RustClient::VERSION
|
|
113
173
|
raise Invalid, 'Documentation Rust generator version mismatch'
|
|
114
174
|
end
|
|
115
175
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
176
|
+
legacy = config.has_key?(:generator_config_path)
|
|
177
|
+
if legacy && (config.has_key?(:crate_name) || config.has_key?(:crate_version))
|
|
178
|
+
raise Invalid, 'Conflicting Rust documentation client identities'
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
documentation = HaveAPI::RustClient::Documentation
|
|
182
|
+
snapshot = JSON.parse(File.read(local_file(config[:snapshot_path]))) if config.has_key?(:snapshot_path)
|
|
183
|
+
if (!legacy || !snapshot) && !(documentation.respond_to?(:from_description) && documentation.respond_to?(:from_snapshot))
|
|
184
|
+
raise Invalid, 'Documentation Rust generator does not support automatic or inline profiles'
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
source = if config.has_key?(:source)
|
|
188
|
+
raise Invalid, 'Documentation Rust generator does not support source metadata' unless documentation.respond_to?(:validate_source)
|
|
189
|
+
|
|
190
|
+
documentation.validate_source(config[:source])
|
|
191
|
+
end
|
|
192
|
+
generator_config = Tomlrb.load_file(local_file(config[:generator_config_path])) if legacy
|
|
193
|
+
if legacy
|
|
194
|
+
HaveAPI::RustClient::Generator.validate_config(generator_config) unless snapshot
|
|
195
|
+
name = generator_config.fetch('crate_name')
|
|
196
|
+
version = generator_config.fetch('crate_version')
|
|
197
|
+
else
|
|
198
|
+
name = config[:crate_name]
|
|
199
|
+
version = config[:crate_version]
|
|
200
|
+
HaveAPI::RustClient::Model.validate_package(crate_name: name, crate_version: version)
|
|
201
|
+
end
|
|
202
|
+
prepared = { name: name.dup.freeze, version: version&.dup&.freeze,
|
|
203
|
+
snapshot:, generator_config:, source: }.freeze
|
|
204
|
+
return prepared unless snapshot
|
|
205
|
+
|
|
206
|
+
prepared.merge(profile: rust_profile(prepared, snapshot:)).freeze
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def build_profile(language, config, description, server, version)
|
|
210
|
+
return go_profile(config, description) if language == :go
|
|
211
|
+
|
|
212
|
+
arguments = { description:, api_version: version, protocol_version: HaveAPI::PROTOCOL_VERSION,
|
|
213
|
+
mount_path: server.root }
|
|
214
|
+
if config[:generator_config]
|
|
215
|
+
snapshot = HaveAPI::RustClient::Snapshot.from_description(
|
|
216
|
+
**arguments, source_url: "https://haveapi.invalid#{server.root}v#{version}/", profile: 'server-documentation'
|
|
217
|
+
)
|
|
218
|
+
return rust_profile(config, snapshot:)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
adapter = HaveAPI::RustClient::Documentation.from_description(
|
|
222
|
+
**arguments, crate_name: config[:name], crate_version: config[:version], source: config[:source]
|
|
223
|
+
)
|
|
224
|
+
profile(:rust, config, adapter)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def rust_profile(config, snapshot:)
|
|
228
|
+
adapter = if config[:generator_config]
|
|
229
|
+
arguments = { snapshot:, config: config[:generator_config] }
|
|
230
|
+
arguments[:source] = config[:source] if config[:source]
|
|
231
|
+
HaveAPI::RustClient::Documentation.new(**arguments)
|
|
232
|
+
else
|
|
233
|
+
HaveAPI::RustClient::Documentation.from_snapshot(
|
|
234
|
+
snapshot:, crate_name: config[:name], crate_version: config[:version], source: config[:source]
|
|
235
|
+
)
|
|
236
|
+
end
|
|
237
|
+
profile(:rust, config, adapter)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def profile(language, config, adapter)
|
|
241
|
+
unless adapter.respond_to?(:resolve) && adapter.respond_to?(:render)
|
|
242
|
+
raise Invalid, 'Documentation client generator has an incompatible adapter interface'
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
Profile.new(language:, package_name: config[:name], package_version: config[:version], adapter:)
|
|
123
246
|
end
|
|
124
247
|
end
|
|
125
248
|
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
module HaveAPI
|
|
4
|
+
class ClientExample
|
|
5
|
+
# Private naming/type input, never a request's authorized help or samples.
|
|
6
|
+
module StructuralSchema
|
|
7
|
+
BUILTIN_VALIDATORS = %w[present number include exclude format length confirm accept].freeze
|
|
8
|
+
|
|
9
|
+
def self.copy(description)
|
|
10
|
+
result = JSON.parse(JSON.generate(description))
|
|
11
|
+
resources(result['resources'])
|
|
12
|
+
result.fetch('authentication', {}).each_value do |provider|
|
|
13
|
+
prose(provider)
|
|
14
|
+
resources(provider['resources'])
|
|
15
|
+
end
|
|
16
|
+
freeze_data(result)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.resources(map)
|
|
20
|
+
return unless map
|
|
21
|
+
|
|
22
|
+
map.each_value do |resource|
|
|
23
|
+
prose(resource)
|
|
24
|
+
resource.fetch('actions', {}).each_value do |action|
|
|
25
|
+
prose(action)
|
|
26
|
+
action.delete('examples')
|
|
27
|
+
parameters(action['input'])
|
|
28
|
+
parameters(action['output'])
|
|
29
|
+
action.fetch('meta', nil)&.each_value do |metadata|
|
|
30
|
+
next unless metadata
|
|
31
|
+
|
|
32
|
+
parameters(metadata['input'])
|
|
33
|
+
parameters(metadata['output'])
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
resources(resource['resources'])
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.parameters(group)
|
|
41
|
+
return unless group
|
|
42
|
+
|
|
43
|
+
group.fetch('parameters', {}).each_value do |parameter|
|
|
44
|
+
prose(parameter)
|
|
45
|
+
parameter.delete('default')
|
|
46
|
+
parameter.fetch('validators', {}).each do |name, validator|
|
|
47
|
+
prose(validator) if BUILTIN_VALIDATORS.include?(name) && validator.is_a?(Hash)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.prose(object)
|
|
53
|
+
%w[label description message].each { |key| object.delete(key) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.freeze_data(value)
|
|
57
|
+
case value
|
|
58
|
+
when Hash
|
|
59
|
+
value.each do |key, item|
|
|
60
|
+
freeze_data(key)
|
|
61
|
+
freeze_data(item)
|
|
62
|
+
end
|
|
63
|
+
when Array
|
|
64
|
+
value.each { |item| freeze_data(item) }
|
|
65
|
+
end
|
|
66
|
+
value.freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private_class_method :resources, :parameters, :prose, :freeze_data
|
|
70
|
+
end
|
|
71
|
+
private_constant :StructuralSchema
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/haveapi/server.rb
CHANGED
|
@@ -622,6 +622,11 @@ module HaveAPI
|
|
|
622
622
|
end
|
|
623
623
|
|
|
624
624
|
call_hooks_for(:post_mount, args: [self, @sinatra])
|
|
625
|
+
@documentation_clients.finalize(self)
|
|
626
|
+
rescue StandardError
|
|
627
|
+
@documentation_clients.abort_mount
|
|
628
|
+
@sinatra = nil
|
|
629
|
+
raise
|
|
625
630
|
end
|
|
626
631
|
|
|
627
632
|
def mount_version(prefix, v)
|
data/lib/haveapi/version.rb
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
<li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/js" target="_blank" rel="noopener">JavaScript and TypeScript (callback and async)</a></li>
|
|
21
21
|
<li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/python" target="_blank" rel="noopener">Python (sync and async)</a></li>
|
|
22
22
|
<% settings.api_server.documentation_client_profiles(@v).each do |language, profile| %>
|
|
23
|
-
<li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/<%= escape_html(language) %>" target="_blank" rel="noopener"><%= language == :go ? 'Go' : 'Rust (async and blocking)' %></a>: <code><%= escape_html(profile.package_name) %></code
|
|
23
|
+
<li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/<%= escape_html(language) %>" target="_blank" rel="noopener"><%= language == :go ? 'Go' : 'Rust (async and blocking)' %></a>: <code><%= escape_html(profile.package_name) %></code><% if profile.package_version %> <%= escape_html(profile.package_version) %><% end %></li>
|
|
24
24
|
<% end %>
|
|
25
25
|
<li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/php" target="_blank" rel="noopener">PHP</a></li>
|
|
26
26
|
<li>
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
<p>
|
|
33
33
|
These examples target API v<%= escape_html(@v) %>. The framework and dynamic
|
|
34
34
|
clients share HaveAPI package versions. Generated Go and Rust SDKs use their
|
|
35
|
-
own package versions
|
|
35
|
+
own package versions. Their configured SDKs are expected to match this API.
|
|
36
36
|
</p>
|
|
37
37
|
|
|
38
38
|
<h2>Initialization</h2>
|
|
@@ -83,6 +83,7 @@ RSpec.describe HaveAPI::ClientExamples::JsClient do
|
|
|
83
83
|
end
|
|
84
84
|
end
|
|
85
85
|
options = { strict: true, noEmit: true, target: 'ES2020', module: 'ESNext', moduleResolution: 'node',
|
|
86
|
+
ignoreDeprecations: '6.0',
|
|
86
87
|
typeRoots: [File.join(root, 'clients/js/node_modules/@types')], types: ['node'],
|
|
87
88
|
baseUrl: directory, paths: { 'haveapi-client' => [File.join(root, 'clients/js/dist/types/haveapi')] } }
|
|
88
89
|
config = File.join(directory, 'tsconfig.json')
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'base64'
|
|
3
|
+
|
|
4
|
+
module ProfileMountSpec
|
|
5
|
+
User = Struct.new(:login)
|
|
6
|
+
|
|
7
|
+
module AuthenticationResources
|
|
8
|
+
class Session < HaveAPI::Resource
|
|
9
|
+
auth false
|
|
10
|
+
version :all
|
|
11
|
+
|
|
12
|
+
class Show < HaveAPI::Action
|
|
13
|
+
route ''
|
|
14
|
+
http_method :get
|
|
15
|
+
output(:hash) { string :id }
|
|
16
|
+
authorize { allow }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Provider < HaveAPI::Authentication::Basic::Provider
|
|
22
|
+
def resource_module
|
|
23
|
+
AuthenticationResources
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
protected
|
|
27
|
+
|
|
28
|
+
def find_user(_request, username, password)
|
|
29
|
+
User.new(username) if %w[member admin].include?(username) && password == 'pass'
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class Added < HaveAPI::Resource
|
|
34
|
+
auth false
|
|
35
|
+
version :all
|
|
36
|
+
|
|
37
|
+
class Index < HaveAPI::Action
|
|
38
|
+
route ''
|
|
39
|
+
http_method :get
|
|
40
|
+
output(:hash) { string :late }
|
|
41
|
+
authorize { allow }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
describe HaveAPI::Server, '#mount' do
|
|
47
|
+
api do
|
|
48
|
+
define_resource(:Widget) do
|
|
49
|
+
version :all
|
|
50
|
+
auth false
|
|
51
|
+
|
|
52
|
+
define_action(:Show) do
|
|
53
|
+
route '{widget_id}'
|
|
54
|
+
http_method :get
|
|
55
|
+
desc HaveAPI.message('profile_mount_spec.action')
|
|
56
|
+
input(:hash) do
|
|
57
|
+
string :visible
|
|
58
|
+
string :secret, default: 'PRIVATE_DEFAULT'
|
|
59
|
+
end
|
|
60
|
+
output(:hash) do
|
|
61
|
+
string :visible
|
|
62
|
+
string :secret
|
|
63
|
+
end
|
|
64
|
+
authorize do |user|
|
|
65
|
+
unless user.login == 'admin'
|
|
66
|
+
input blacklist: [:secret]
|
|
67
|
+
output blacklist: [:secret]
|
|
68
|
+
end
|
|
69
|
+
allow
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# rubocop:disable RSpec/NoExpectationExample
|
|
73
|
+
example 'visible sample' do
|
|
74
|
+
path_params 7
|
|
75
|
+
request({ visible: 'public sample' })
|
|
76
|
+
response({ visible: 'public response' })
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
example 'privileged sample' do
|
|
80
|
+
authorize { |user| user.login == 'admin' }
|
|
81
|
+
path_params 7
|
|
82
|
+
request({ secret: 'PRIVATE_SAMPLE' })
|
|
83
|
+
response({ secret: 'PRIVATE_RESPONSE' })
|
|
84
|
+
end
|
|
85
|
+
# rubocop:enable RSpec/NoExpectationExample
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
define_action(:Secret) do
|
|
89
|
+
route 'secret'
|
|
90
|
+
http_method :get
|
|
91
|
+
output(:hash) { string :admin_only }
|
|
92
|
+
authorize { |user| user.login == 'admin' ? allow : deny }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
let(:server) { described_class.new(get_opt(:api_module)) }
|
|
98
|
+
let(:descriptions) { [] }
|
|
99
|
+
let(:contexts) { [] }
|
|
100
|
+
let(:documentation) { Class.new }
|
|
101
|
+
let(:adapter) do
|
|
102
|
+
selected_contexts = contexts
|
|
103
|
+
Class.new do
|
|
104
|
+
define_method(:resolve) { |**| nil }
|
|
105
|
+
define_method(:render) do |kind:, context:, **|
|
|
106
|
+
selected_contexts << context
|
|
107
|
+
{ status: :code, code: JSON.generate(kind == :example ? context.sample : { url: context.version_url }) }
|
|
108
|
+
end
|
|
109
|
+
end.new
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
before do
|
|
113
|
+
generator = Module.new
|
|
114
|
+
generator.const_set(:VERSION, HaveAPI::VERSION)
|
|
115
|
+
generator.const_set(:Documentation, documentation)
|
|
116
|
+
stub_const('HaveAPI::GoClient', generator)
|
|
117
|
+
allow(registry).to receive(:require).with('haveapi/go_client/documentation').and_return(true)
|
|
118
|
+
allow(documentation).to receive(:new) do |description:, **|
|
|
119
|
+
descriptions << description
|
|
120
|
+
adapter
|
|
121
|
+
end
|
|
122
|
+
server.use_version([1, 2])
|
|
123
|
+
server.default_version = 1
|
|
124
|
+
server.auth_chain << ProfileMountSpec::Provider
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def registry
|
|
128
|
+
server.instance_variable_get(:@documentation_clients)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def register(version = 1)
|
|
132
|
+
server.documentation_clients(version, go: { import_path: 'example.test/widget/client' })
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def request_as(user, locale)
|
|
136
|
+
Rack::MockRequest.new(server.app).get(
|
|
137
|
+
'/mounted/api/v1/', 'HTTP_HOST' => 'example.org', 'HTTP_ACCEPT_LANGUAGE' => locale,
|
|
138
|
+
'HTTP_AUTHORIZATION' => "Basic #{Base64.strict_encode64("#{user}:pass")}"
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'captures the complete selected version after auth, action-state and post-mount routes exist' do
|
|
143
|
+
register
|
|
144
|
+
server.action_state = Object.new
|
|
145
|
+
server.connect_hook(:post_mount) do |_ret, api_server, _sinatra|
|
|
146
|
+
expect(api_server.documentation_client_profiles(1)).to be_empty
|
|
147
|
+
api_server.mount_resource('/mounted/api/v1/', 1, ProfileMountSpec::Added, api_server.routes[1][:resources])
|
|
148
|
+
{}
|
|
149
|
+
end
|
|
150
|
+
allow(server).to receive(:describe_version).and_call_original
|
|
151
|
+
server.mount('/mounted/api/')
|
|
152
|
+
expect(server).to have_received(:describe_version).once
|
|
153
|
+
expect(descriptions.size).to eq(1)
|
|
154
|
+
private_schema = descriptions.first
|
|
155
|
+
expect(private_schema['help']).to eq('/mounted/api/v1/')
|
|
156
|
+
expect(private_schema['resources'].keys).to include('widget', 'action_state', 'added')
|
|
157
|
+
expect(private_schema.dig('resources', 'widget', 'actions').keys).to include('show', 'secret')
|
|
158
|
+
expect(private_schema.dig('authentication', 'basic', 'resources')).to have_key('session')
|
|
159
|
+
expect(private_schema.dig('resources', 'widget', 'actions', 'show', 'input', 'parameters')).to have_key('secret')
|
|
160
|
+
expect(JSON.generate(private_schema)).not_to include('PRIVATE_DEFAULT', 'PRIVATE_SAMPLE', 'PRIVATE_RESPONSE')
|
|
161
|
+
expect(server.documentation_client_profiles(1).fetch(:go).package_version).to be_nil
|
|
162
|
+
expect(server.documentation_client_profiles(2)).to be_empty
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'captures each configured version once and does not initialize from requests' do
|
|
166
|
+
register(1)
|
|
167
|
+
register(2)
|
|
168
|
+
server.mount('/mounted/api/')
|
|
169
|
+
expect(descriptions.map { |description| description['help'] }).to eq(['/mounted/api/v1/', '/mounted/api/v2/'])
|
|
170
|
+
request_as('member', 'en')
|
|
171
|
+
request_as('admin', 'cs')
|
|
172
|
+
expect(descriptions.size).to eq(2)
|
|
173
|
+
expect { register }.to raise_error(HaveAPI::ClientExample::Profiles::Invalid, /already mounted/)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it 'keeps private schema separate from sequential users, locales, HTML and samples' do
|
|
177
|
+
register
|
|
178
|
+
server.available_locales = %i[en cs]
|
|
179
|
+
::I18n.backend.store_translations(:en, profile_mount_spec: { action: 'Visible English action' })
|
|
180
|
+
::I18n.backend.store_translations(:cs, profile_mount_spec: { action: 'Viditelna ceska akce' })
|
|
181
|
+
server.mount('/mounted/api/')
|
|
182
|
+
privileged = request_as('admin', 'en')
|
|
183
|
+
expect(privileged.status).to eq(200)
|
|
184
|
+
expect(privileged.body).to include('PRIVATE_SAMPLE', 'Visible English action')
|
|
185
|
+
contexts.clear
|
|
186
|
+
public_response = request_as('member', 'cs')
|
|
187
|
+
expect(public_response.status).to eq(200)
|
|
188
|
+
expect(public_response.body).to include('Viditelna ceska akce', 'init-go', 'example.test/widget/client')
|
|
189
|
+
expect(public_response.body).not_to include('PRIVATE_DEFAULT', 'PRIVATE_SAMPLE', 'PRIVATE_RESPONSE', 'admin_only', 'haveapi.invalid', 'StructuralSchema')
|
|
190
|
+
selected = contexts.find { |context| context.action_name == 'show' }
|
|
191
|
+
expect(selected.action.dig(:input, :parameters).keys.map(&:to_s)).to eq(['visible'])
|
|
192
|
+
expect(selected.description[:resources]['widget'][:actions]).not_to have_key('secret')
|
|
193
|
+
expect(selected.sample[:request]).to eq(visible: 'public sample')
|
|
194
|
+
expect(selected.action[:description]).to eq('Viditelna ceska akce')
|
|
195
|
+
expect(request_as('member', 'en').body).to include('Visible English action')
|
|
196
|
+
expect(descriptions.size).to eq(1)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
it 'makes the server unusable and publishes no profiles after an adapter failure' do
|
|
200
|
+
register
|
|
201
|
+
allow(documentation).to receive(:new).and_raise('PRIVATE_SCHEMA_DIAGNOSTIC')
|
|
202
|
+
expect { server.mount('/') }.to raise_error(HaveAPI::ClientExample::Profiles::Invalid, 'Configured documentation client schema or configuration is invalid')
|
|
203
|
+
expect(server.app).to be_nil
|
|
204
|
+
expect(server.documentation_client_profiles(1)).to be_empty
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
it 'makes the server unusable when a post-mount hook fails before finalization' do
|
|
208
|
+
register
|
|
209
|
+
server.connect_hook(:post_mount) { raise 'fixture mount failure' }
|
|
210
|
+
expect { server.mount('/') }.to raise_error(RuntimeError, 'fixture mount failure')
|
|
211
|
+
expect(documentation).not_to have_received(:new)
|
|
212
|
+
expect(server.app).to be_nil
|
|
213
|
+
expect(server.documentation_client_profiles(1)).to be_empty
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -3,22 +3,46 @@ require 'tmpdir'
|
|
|
3
3
|
|
|
4
4
|
describe HaveAPI::ClientExample::Profiles do
|
|
5
5
|
let(:profiles) { described_class.new }
|
|
6
|
+
let(:adapter) do
|
|
7
|
+
Class.new do
|
|
8
|
+
def resolve(**); end
|
|
9
|
+
def render(**); end
|
|
10
|
+
end.new
|
|
11
|
+
end
|
|
6
12
|
let(:directory) { Dir.mktmpdir('haveapi-doc-profiles-') }
|
|
7
|
-
let(:
|
|
8
|
-
let(:
|
|
9
|
-
|
|
13
|
+
let(:description) { { resources: {}, authentication: {}, meta: { namespace: '_meta' } } }
|
|
14
|
+
let(:server) { instance_double(HaveAPI::Server, versions: [1], root: '/', describe_version: description) }
|
|
15
|
+
|
|
16
|
+
def description_path
|
|
17
|
+
File.join(directory, 'description.json')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def config_path
|
|
21
|
+
File.join(directory, 'generator.toml')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def go_config
|
|
25
|
+
{ import_path: 'example.test/widget/client', module_version: 'v1.2.3', description_path:, generator_version: HaveAPI::VERSION }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def rust_config
|
|
29
|
+
{ snapshot_path: description_path, generator_config_path: config_path, generator_version: HaveAPI::VERSION }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def generator_config
|
|
33
|
+
{ 'crate_name' => 'widget-client', 'crate_version' => '1.2.3' }
|
|
10
34
|
end
|
|
11
|
-
let(:adapter) { Object.new }
|
|
12
35
|
|
|
13
36
|
before do
|
|
14
|
-
File.write(description_path, JSON.generate(
|
|
37
|
+
File.write(description_path, JSON.generate(description))
|
|
38
|
+
File.write(config_path, 'fixture config')
|
|
15
39
|
end
|
|
16
40
|
|
|
17
41
|
after { FileUtils.remove_entry(directory) }
|
|
18
42
|
|
|
19
|
-
def install_go_adapter
|
|
43
|
+
def install_go_adapter(version: HaveAPI::VERSION)
|
|
20
44
|
generator = Module.new
|
|
21
|
-
generator.const_set(:VERSION,
|
|
45
|
+
generator.const_set(:VERSION, version)
|
|
22
46
|
documentation = Class.new
|
|
23
47
|
generator.const_set(:Documentation, documentation)
|
|
24
48
|
stub_const('HaveAPI::GoClient', generator)
|
|
@@ -27,32 +51,89 @@ describe HaveAPI::ClientExample::Profiles do
|
|
|
27
51
|
documentation
|
|
28
52
|
end
|
|
29
53
|
|
|
30
|
-
|
|
31
|
-
|
|
54
|
+
def install_rust_adapter(factories: true)
|
|
55
|
+
generator = Module.new
|
|
56
|
+
generator.const_set(:VERSION, HaveAPI::VERSION)
|
|
57
|
+
documentation = Class.new
|
|
58
|
+
generator.const_set(:Documentation, documentation)
|
|
59
|
+
generator.const_set(:Generator, Class.new)
|
|
60
|
+
generator.const_set(:Model, Module.new)
|
|
61
|
+
generator.const_set(:Snapshot, Class.new)
|
|
62
|
+
stub_const('HaveAPI::RustClient', generator)
|
|
63
|
+
stub_const('Tomlrb', Module.new)
|
|
64
|
+
allow(Tomlrb).to receive(:load_file).with(config_path).and_return(generator_config)
|
|
65
|
+
allow(profiles).to receive(:require).with('haveapi/rust_client/documentation').and_return(true)
|
|
66
|
+
allow(documentation).to receive(:new).and_return(adapter)
|
|
67
|
+
if factories
|
|
68
|
+
allow(documentation).to receive_messages(from_description: adapter, from_snapshot: adapter)
|
|
69
|
+
allow(documentation).to receive(:validate_source) { |source| source }
|
|
70
|
+
allow(HaveAPI::RustClient::Generator).to receive(:validate_config)
|
|
71
|
+
allow(HaveAPI::RustClient::Model).to receive(:validate_package)
|
|
72
|
+
end
|
|
73
|
+
documentation
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def finalize
|
|
32
77
|
profiles.mount([1])
|
|
78
|
+
profiles.finalize(server)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'needs no optional generator or schema when profiles are absent' do
|
|
82
|
+
allow(profiles).to receive(:require).and_raise('Unexpected optional dependency')
|
|
83
|
+
finalize
|
|
33
84
|
expect(profiles).not_to have_received(:require)
|
|
85
|
+
expect(server).not_to have_received(:describe_version)
|
|
34
86
|
expect(profiles.for_version(1)).to eq({})
|
|
35
87
|
expect(profiles.for_version(1)).to be_frozen
|
|
36
88
|
end
|
|
37
89
|
|
|
38
|
-
it 'loads
|
|
90
|
+
it 'loads an explicit saved adapter once without rereading it during finalization' do
|
|
39
91
|
documentation = install_go_adapter
|
|
40
92
|
profiles.register(1, go: go_config)
|
|
41
|
-
profiles.
|
|
93
|
+
expect(profiles.for_version(1)).to be_empty
|
|
42
94
|
File.unlink(description_path)
|
|
95
|
+
finalize
|
|
43
96
|
profile = profiles.for_version(1).fetch(:go)
|
|
44
97
|
expect(profile).to have_attributes(language: :go, package_name: 'example.test/widget/client', package_version: 'v1.2.3')
|
|
45
98
|
expect(profile).to be_frozen
|
|
46
99
|
expect(profile.adapter).to equal(adapter)
|
|
47
|
-
expect(documentation).to have_received(:new).once.with(description:
|
|
100
|
+
expect(documentation).to have_received(:new).once.with(description: JSON.parse(JSON.generate(description)), import_path: 'example.test/widget/client', module_version: 'v1.2.3')
|
|
101
|
+
expect(server).not_to have_received(:describe_version)
|
|
48
102
|
expect(profile).not_to respond_to(:description_path)
|
|
49
103
|
end
|
|
50
104
|
|
|
105
|
+
it 'defaults the generator version and defers automatic Go without inventing an SDK version' do
|
|
106
|
+
documentation = install_go_adapter
|
|
107
|
+
config = { import_path: 'example.test/widget/client' }
|
|
108
|
+
profiles.register(1, go: config)
|
|
109
|
+
config[:import_path].replace('changed.test/client')
|
|
110
|
+
expect(documentation).not_to have_received(:new)
|
|
111
|
+
finalize
|
|
112
|
+
profile = profiles.for_version(1).fetch(:go)
|
|
113
|
+
expect(profile.package_name).to eq('example.test/widget/client')
|
|
114
|
+
expect(profile.package_version).to be_nil
|
|
115
|
+
expect(documentation).to have_received(:new).with(description: JSON.parse(JSON.generate(description)), import_path: 'example.test/widget/client', module_version: nil)
|
|
116
|
+
expect(server).to have_received(:describe_version).once
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'permits nil SDK versions and retains explicitly pinned generator versions' do
|
|
120
|
+
documentation = install_go_adapter(version: '0.29.0')
|
|
121
|
+
profiles.register(1, go: go_config.merge(module_version: nil, generator_version: '0.29.0'))
|
|
122
|
+
finalize
|
|
123
|
+
expect(profiles.for_version(1).fetch(:go).package_version).to be_nil
|
|
124
|
+
expect(documentation).to have_received(:new).with(description: anything, import_path: anything, module_version: nil)
|
|
125
|
+
end
|
|
126
|
+
|
|
51
127
|
it 'rejects unknown languages, malformed fields and unsafe package configuration' do
|
|
52
128
|
expect { profiles.register(1, python: {}) }.to raise_error(described_class::Invalid, /Unknown/)
|
|
53
129
|
expect { profiles.register(1, go: go_config.merge(unexpected: 'value')) }.to raise_error(described_class::Invalid, /fields/)
|
|
54
130
|
expect { profiles.register(1, go: go_config.merge(import_path: "example.test/\nsource")) }.to raise_error(described_class::Invalid, /package/)
|
|
55
|
-
|
|
131
|
+
['latest', '', 1, false].each do |value|
|
|
132
|
+
expect { profiles.register(1, go: go_config.merge(module_version: value)) }.to raise_error(described_class::Invalid, /package/)
|
|
133
|
+
end
|
|
134
|
+
expect { profiles.register(1, go: go_config.merge(generator_version: nil)) }.to raise_error(described_class::Invalid)
|
|
135
|
+
expect { profiles.register(1, go: go_config.merge(generator_version: 'latest')) }.to raise_error(described_class::Invalid, /generator version/)
|
|
136
|
+
expect { profiles.register(1, go: {}) }.to raise_error(described_class::Invalid, /package/)
|
|
56
137
|
end
|
|
57
138
|
|
|
58
139
|
it 'rejects missing adapters, mismatched generator versions and missing files at registration' do
|
|
@@ -63,33 +144,122 @@ describe HaveAPI::ClientExample::Profiles do
|
|
|
63
144
|
expect { profiles.register(1, go: go_config.merge(description_path: 'relative.json')) }.to raise_error(described_class::Invalid, /absolute local/)
|
|
64
145
|
end
|
|
65
146
|
|
|
66
|
-
it '
|
|
147
|
+
it 'checks the configured adapter interface' do
|
|
148
|
+
documentation = install_go_adapter
|
|
149
|
+
allow(documentation).to receive(:new).and_return(Object.new)
|
|
150
|
+
expect { profiles.register(1, go: go_config) }.to raise_error(described_class::Invalid, /adapter interface/)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it 'does not leave a partially registered language set after an invalid second profile' do
|
|
154
|
+
install_go_adapter
|
|
155
|
+
expect { profiles.register(1, go: go_config, rust: { unknown: 'field' }) }.to raise_error(described_class::Invalid)
|
|
156
|
+
profiles.register(1, go: go_config)
|
|
157
|
+
finalize
|
|
158
|
+
expect(profiles.for_version(1).keys).to eq([:go])
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it 'rejects duplicates and registration after mounting starts' do
|
|
67
162
|
install_go_adapter
|
|
68
163
|
profiles.register(1, go: go_config)
|
|
69
164
|
expect { profiles.register('1', go: go_config) }.to raise_error(described_class::Invalid, /Duplicate/)
|
|
70
|
-
expect { profiles.mount([2]) }.to raise_error(described_class::Invalid, /unavailable API version/)
|
|
71
165
|
profiles.mount([1])
|
|
72
166
|
expect { profiles.register(2, go: go_config) }.to raise_error(described_class::Invalid, /already mounted/)
|
|
167
|
+
profiles.finalize(server)
|
|
168
|
+
expect { profiles.finalize(server) }.to raise_error(described_class::Invalid, /not ready/)
|
|
73
169
|
end
|
|
74
170
|
|
|
75
|
-
it '
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
File.write(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
allow(profiles).to receive(:require).with('haveapi/rust_client/documentation').and_return(true)
|
|
89
|
-
allow(documentation).to receive(:new).and_return(adapter)
|
|
90
|
-
profiles.register(1, rust: { snapshot_path: description_path, generator_config_path: config_path, generator_version: '0.30.0' })
|
|
91
|
-
expect(documentation).to have_received(:new).with(snapshot: { 'api_version' => '2' }, config:)
|
|
171
|
+
it 'rejects profiles for an unavailable version' do
|
|
172
|
+
install_go_adapter
|
|
173
|
+
profiles.register(1, go: go_config)
|
|
174
|
+
expect { profiles.mount([2]) }.to raise_error(described_class::Invalid, /unavailable API version/)
|
|
175
|
+
expect(profiles.for_version(1)).to be_empty
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it 'preserves legacy Rust construction even without the new factories' do
|
|
179
|
+
documentation = install_rust_adapter(factories: false)
|
|
180
|
+
File.write(description_path, JSON.generate({ api_version: '1' }))
|
|
181
|
+
profiles.register(1, rust: rust_config)
|
|
182
|
+
finalize
|
|
183
|
+
expect(documentation).to have_received(:new).with(snapshot: { 'api_version' => '1' }, config: generator_config)
|
|
92
184
|
expect(profiles.for_version(1).fetch(:rust)).to have_attributes(package_name: 'widget-client', package_version: '1.2.3')
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it 'rejects a saved Rust snapshot for a different selected API version at mount' do
|
|
188
|
+
install_rust_adapter
|
|
189
|
+
File.write(description_path, JSON.generate({ api_version: '2' }))
|
|
190
|
+
profiles.register(1, rust: rust_config)
|
|
93
191
|
expect { profiles.mount([1]) }.to raise_error(described_class::Invalid, /snapshot API version mismatch/)
|
|
192
|
+
expect(profiles.for_version(1)).to be_empty
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it 'validates inline Rust identity and source before building from the mounted description' do
|
|
196
|
+
documentation = install_rust_adapter
|
|
197
|
+
source = { git: 'https://example.test/widget-client', rev: 'a' * 40 }
|
|
198
|
+
profiles.register(1, rust: { crate_name: 'widget-client', source: })
|
|
199
|
+
expect(HaveAPI::RustClient::Model).to have_received(:validate_package).with(crate_name: 'widget-client', crate_version: nil)
|
|
200
|
+
expect(documentation).to have_received(:validate_source).with(source)
|
|
201
|
+
expect(documentation).not_to have_received(:from_description)
|
|
202
|
+
finalize
|
|
203
|
+
expect(documentation).to have_received(:from_description).with(description: anything, api_version: '1', protocol_version: HaveAPI::PROTOCOL_VERSION,
|
|
204
|
+
mount_path: '/', crate_name: 'widget-client', crate_version: nil, source:)
|
|
205
|
+
expect(profiles.for_version(1).fetch(:rust).package_version).to be_nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it 'supports an inline Rust identity with a saved snapshot and optional explicit SDK version' do
|
|
209
|
+
documentation = install_rust_adapter
|
|
210
|
+
snapshot = { 'api_version' => '1' }
|
|
211
|
+
File.write(description_path, JSON.generate(snapshot))
|
|
212
|
+
profiles.register(1, rust: { snapshot_path: description_path, crate_name: 'widget-client', crate_version: '1.2.3' })
|
|
213
|
+
finalize
|
|
214
|
+
expect(documentation).to have_received(:from_snapshot).with(snapshot:, crate_name: 'widget-client', crate_version: '1.2.3', source: nil)
|
|
215
|
+
expect(server).not_to have_received(:describe_version)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
it 'validates full legacy TOML before using it with an automatic Rust description' do
|
|
219
|
+
documentation = install_rust_adapter
|
|
220
|
+
snapshot = { 'api_version' => '1' }
|
|
221
|
+
allow(HaveAPI::RustClient::Snapshot).to receive(:from_description).and_return(snapshot)
|
|
222
|
+
profiles.register(1, rust: { generator_config_path: config_path })
|
|
223
|
+
expect(HaveAPI::RustClient::Generator).to have_received(:validate_config).with(generator_config)
|
|
224
|
+
finalize
|
|
225
|
+
expect(HaveAPI::RustClient::Snapshot).to have_received(:from_description).with(description: anything, api_version: '1', protocol_version: HaveAPI::PROTOCOL_VERSION,
|
|
226
|
+
mount_path: '/', source_url: 'https://haveapi.invalid/v1/', profile: 'server-documentation')
|
|
227
|
+
expect(documentation).to have_received(:new).with(snapshot:, config: generator_config)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
it 'rejects automatic and inline modes when an older Rust generator lacks their factories' do
|
|
231
|
+
install_rust_adapter(factories: false)
|
|
232
|
+
expect { profiles.register(1, rust: { generator_config_path: config_path }) }.to raise_error(described_class::Invalid, /does not support/)
|
|
233
|
+
expect { profiles.register(1, rust: { crate_name: 'widget-client', snapshot_path: description_path }) }.to raise_error(described_class::Invalid, /does not support/)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
it 'rejects conflicting Rust identities and unknown source profile fields' do
|
|
237
|
+
install_rust_adapter
|
|
238
|
+
expect { profiles.register(1, rust: rust_config.merge(crate_name: 'widget-client')) }.to raise_error(described_class::Invalid, /Conflicting/)
|
|
239
|
+
expect { profiles.register(1, rust: rust_config.merge(crate_version: nil)) }.to raise_error(described_class::Invalid, /Conflicting/)
|
|
240
|
+
expect { profiles.register(1, rust: { crate_name: 'widget-client', branch: 'main' }) }.to raise_error(described_class::Invalid, /fields/)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
it 'captures one private structural schema per version shared by automatic profiles' do
|
|
244
|
+
go = install_go_adapter
|
|
245
|
+
rust = install_rust_adapter
|
|
246
|
+
profiles.register(1, go: { import_path: 'example.test/client' }, rust: { crate_name: 'widget-client' })
|
|
247
|
+
finalize
|
|
248
|
+
expect(server).to have_received(:describe_version).once do |context|
|
|
249
|
+
expect(context).to have_attributes(server:, version: 1, current_user: nil, endpoint: false, doc: true)
|
|
250
|
+
end
|
|
251
|
+
expect(go).to have_received(:new).with(description: anything, import_path: anything, module_version: nil)
|
|
252
|
+
expect(rust).to have_received(:from_description).with(description: anything, api_version: '1', protocol_version: anything, mount_path: '/', crate_name: anything, crate_version: nil, source: nil)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
it 'publishes no partial profiles and hides private diagnostics when finalization fails' do
|
|
256
|
+
install_go_adapter
|
|
257
|
+
rust = install_rust_adapter
|
|
258
|
+
allow(rust).to receive(:from_description).and_raise('HIDDEN_SCHEMA_DEFAULT')
|
|
259
|
+
profiles.register(1, go: { import_path: 'example.test/client' }, rust: { crate_name: 'widget-client' })
|
|
260
|
+
profiles.mount([1])
|
|
261
|
+
expect { profiles.finalize(server) }.to raise_error(described_class::Invalid, 'Configured documentation client schema or configuration is invalid')
|
|
262
|
+
expect(profiles.for_version(1)).to be_empty
|
|
263
|
+
expect { profiles.register(1, go: go_config) }.to raise_error(described_class::Invalid, /already mounted/)
|
|
94
264
|
end
|
|
95
265
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
describe HaveAPI::ClientExample::Profiles do
|
|
4
|
+
let(:schema) { HaveAPI::ClientExample.const_get(:StructuralSchema) }
|
|
5
|
+
let(:data) { { description: 'data description', default: { examples: ['data example'] }, message: 'data message' } }
|
|
6
|
+
let(:parameter) do
|
|
7
|
+
{ type: 'Custom', default: data, description: 'private prose', label: 'private label',
|
|
8
|
+
validators: { include: { values: [data], message: 'private validation message' }, custom: data }, format: data }
|
|
9
|
+
end
|
|
10
|
+
let(:description) do
|
|
11
|
+
action = { path: '/v1/widgets/', input: { layout: :hash, parameters: { default: parameter } },
|
|
12
|
+
output: { parameters: {} }, meta: { global: { input: { parameters: { example: parameter } } } },
|
|
13
|
+
description: 'private action prose', examples: [{ request: data }] }
|
|
14
|
+
resource = { description: 'private resource prose', actions: { index: action }, resources: {} }
|
|
15
|
+
{ resources: { widget: resource }, authentication: { token: { description: 'private auth prose', resources: { token: resource } } } }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'copies structural locations and strips defaults and prose without changing the server description' do
|
|
19
|
+
before = JSON.generate(description)
|
|
20
|
+
result = schema.copy(description)
|
|
21
|
+
action = result.dig('resources', 'widget', 'actions', 'index')
|
|
22
|
+
expect(action).not_to have_key('description')
|
|
23
|
+
expect(action).not_to have_key('examples')
|
|
24
|
+
expect(result.dig('resources', 'widget')).not_to have_key('description')
|
|
25
|
+
expect(result.dig('authentication', 'token')).not_to have_key('description')
|
|
26
|
+
expect(action.dig('input', 'parameters', 'default')).not_to have_key('default')
|
|
27
|
+
expect(action.dig('meta', 'global', 'input', 'parameters', 'example')).not_to have_key('default')
|
|
28
|
+
expect(result.dig('authentication', 'token', 'resources', 'token', 'actions', 'index')).not_to have_key('examples')
|
|
29
|
+
expect(JSON.generate(description)).to eq(before)
|
|
30
|
+
expect(description).not_to be_frozen
|
|
31
|
+
expect(result).to be_frozen
|
|
32
|
+
expect(action.dig('input', 'parameters', 'default')).to be_frozen
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'retains parameter names, layouts, associations and arbitrary validator/Custom data verbatim' do
|
|
36
|
+
parameter[:resource] = %w[widget]
|
|
37
|
+
parameter[:choices] = { path: '/v1/widgets/', method: 'GET' }
|
|
38
|
+
result = schema.copy(description)
|
|
39
|
+
copied = result.dig('resources', 'widget', 'actions', 'index', 'input', 'parameters', 'default')
|
|
40
|
+
expect(copied).to include('type' => 'Custom', 'resource' => ['widget'], 'choices' => { 'path' => '/v1/widgets/', 'method' => 'GET' })
|
|
41
|
+
expect(copied['format']).to eq(JSON.parse(JSON.generate(data)))
|
|
42
|
+
expect(copied.dig('validators', 'include', 'values')).to eq([JSON.parse(JSON.generate(data))])
|
|
43
|
+
expect(copied.dig('validators', 'custom')).to eq(JSON.parse(JSON.generate(data)))
|
|
44
|
+
expect(copied.dig('validators', 'include')).not_to have_key('message')
|
|
45
|
+
expect(result.dig('resources', 'widget', 'actions', 'index', 'input', 'layout')).to eq('hash')
|
|
46
|
+
expect(result.dig('resources', 'widget', 'actions', 'index', 'path')).to eq('/v1/widgets/')
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -79,4 +79,14 @@ describe HaveAPI::ClientExample do
|
|
|
79
79
|
expect(selected.description[:resources]).to have_key('widget')
|
|
80
80
|
expect(selected.sample[:response]).to eq(name: 'documented widget')
|
|
81
81
|
end
|
|
82
|
+
|
|
83
|
+
it 'prints a package identity without inventing an omitted version and escapes it once' do
|
|
84
|
+
profile = HaveAPI::ClientExample::Profile.new(language: :go, package_name: 'example.test/<client>&', package_version: nil, adapter: Object.new)
|
|
85
|
+
allow(app.settings.api_server).to receive(:documentation_client_profiles).and_return({ go: profile })
|
|
86
|
+
allow(HaveAPI::ClientExamples::GoClient).to receive(:render).and_return({ status: :code, code: 'example' })
|
|
87
|
+
get '/v1/'
|
|
88
|
+
expect(last_response.status).to eq(200)
|
|
89
|
+
expect(last_response.body).to include('<code>example.test/<client>&</code></li>')
|
|
90
|
+
expect(last_response.body).not_to include('example.test/<client>', '&lt;', 'version = "latest"')
|
|
91
|
+
end
|
|
82
92
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: haveapi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.30.
|
|
4
|
+
version: 0.30.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jakub Skokan
|
|
@@ -29,14 +29,14 @@ dependencies:
|
|
|
29
29
|
requirements:
|
|
30
30
|
- - "~>"
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: 0.30.
|
|
32
|
+
version: 0.30.1
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: 0.30.
|
|
39
|
+
version: 0.30.1
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
41
|
name: i18n
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -258,6 +258,7 @@ files:
|
|
|
258
258
|
- lib/haveapi/context.rb
|
|
259
259
|
- lib/haveapi/documentation_clients.rb
|
|
260
260
|
- lib/haveapi/documentation_context.rb
|
|
261
|
+
- lib/haveapi/documentation_schema.rb
|
|
261
262
|
- lib/haveapi/example.rb
|
|
262
263
|
- lib/haveapi/example_list.rb
|
|
263
264
|
- lib/haveapi/exceptions.rb
|
|
@@ -354,8 +355,10 @@ files:
|
|
|
354
355
|
- spec/client_examples/dynamic_mounted_paths_spec.rb
|
|
355
356
|
- spec/client_examples/dynamic_python_spec.rb
|
|
356
357
|
- spec/client_examples/go_spec.rb
|
|
358
|
+
- spec/client_examples/profile_mount_spec.rb
|
|
357
359
|
- spec/client_examples/profiles_spec.rb
|
|
358
360
|
- spec/client_examples/rust_client_spec.rb
|
|
361
|
+
- spec/client_examples/structural_schema_spec.rb
|
|
359
362
|
- spec/client_examples/syntax_spec.rb
|
|
360
363
|
- spec/common_spec.rb
|
|
361
364
|
- spec/documentation/auth_filtering_spec.rb
|