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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4d308a5a5e69e262ce771a7ec707783c63af02c38726df08c8d5c2f68bae18d3
4
- data.tar.gz: b70e5a101d631bae5e90d999f50d621643f511fef3a61a45b78f35369e0528c6
3
+ metadata.gz: 3d58d9e427d490d025fec95d60739ea4aa17b4e86cc3fb9978b7c00b751e6e75
4
+ data.tar.gz: e0b958ac9924d4cacaf75513d79690c3ed2519d6b5d1522a0433d92285742887
5
5
  SHA512:
6
- metadata.gz: f3081d61476f4ddeb15b44028ff40a9073587520c13bf776f6e675ebcb41439d236c2f463029c969f589e6342e8ca13c550b3d6ab6c1a6dfb639b1bc6a99a940
7
- data.tar.gz: 6523e8942f1fa7ef18c4fbdab85ea073b1e6df14249c948f270b573c3193de3c8d39046440ca132d9f4f30f9d94393392cedc9170310f419a2be8607bac07d0a
6
+ metadata.gz: 78980868da2569146dccf39ce14309e27b97d51b4a266b7709813faafb191577520f31408dd6ebf1e5310b37bd43197e9b4e838503cd60c5d8b480607e8c5f6d
7
+ data.tar.gz: 5a2a6ad3e686e3d77b86fb83e7e27b5eaf5e2ac573013fa938255b203405fdabe899a605f71cea8088769e81f323b6678be00272889c56d629757356d9170d87
@@ -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. 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.
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
- 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.
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
- 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
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.0'
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.dup.freeze
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
- @versions = {}
28
- @rust_versions = {}
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 @mounted
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 = @versions[version.to_s] ||= {}
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
- selected[language] = load_profile(language, config, version.to_s)
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
- unknown = @versions.keys - Array(versions).map(&:to_s)
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
- unless @rust_versions.all? { |version, saved| version == saved }
48
- raise Invalid, 'Documentation Rust snapshot API version mismatch'
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
- @versions.each_value(&:freeze)
52
- @versions.freeze
53
- @mounted = true
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
- @versions.fetch(version.to_s, {}).dup.freeze
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.sort == KEYS.fetch(language).sort &&
64
- config.values.all? { |value| value.is_a?(String) && !value.strip.empty? }
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
- return if config.fetch(:generator_version).match?(/\A\d+\.\d+\.\d+(?:[-+][\w.-]+)?\z/)
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, version)
79
- validate_config(language, config)
80
- language == :go ? load_go(config) : load_rust(config, version)
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.fetch(:import_path)
93
- version = config.fetch(:module_version)
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
- description = JSON.parse(File.read(local_file(config.fetch(:description_path))))
106
- adapter = HaveAPI::GoClient::Documentation.new(description:, import_path: name, module_version: version)
107
- Profile.new(language: :go, package_name: name, package_version: version, adapter:)
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, api_version)
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
- snapshot = JSON.parse(File.read(local_file(config.fetch(:snapshot_path))))
117
- @rust_versions[api_version] = snapshot.fetch('api_version').to_s
118
- generator_config = Tomlrb.load_file(local_file(config.fetch(:generator_config_path)))
119
- name = generator_config.fetch('crate_name')
120
- version = generator_config.fetch('crate_version')
121
- adapter = HaveAPI::RustClient::Documentation.new(snapshot:, config: generator_config)
122
- Profile.new(language: :rust, package_name: name, package_version: version, adapter:)
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
@@ -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)
@@ -1,4 +1,4 @@
1
1
  module HaveAPI
2
2
  PROTOCOL_VERSION = '2.0'.freeze
3
- VERSION = '0.30.0'.freeze
3
+ VERSION = '0.30.1'.freeze
4
4
  end
@@ -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> <%= escape_html(profile.package_version) %></li>
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 and a saved description of the API.
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(:description_path) { File.join(directory, 'description.json') }
8
- let(:go_config) do
9
- { import_path: 'example.test/widget/client', module_version: 'v1.2.3', description_path:, generator_version: '0.30.0' }
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({ resources: {} }))
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, '0.30.0')
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
- it 'needs no optional generator when profiles are absent' do
31
- allow(profiles).to receive(:require).and_raise('Unexpected optional dependency')
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 a matching configured adapter once and keeps schema paths out of the public profile' do
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.mount([1])
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: { 'resources' => {} }, import_path: 'example.test/widget/client', module_version: 'v1.2.3')
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
- expect { profiles.register(1, go: go_config.merge(module_version: 'latest')) }.to raise_error(described_class::Invalid, /package/)
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 'rejects duplicate profiles, unselected versions and registration after mount' do
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 'loads Rust TOML configuration and rejects a snapshot bound to another API version at mount' do
76
- generator = Module.new
77
- generator.const_set(:VERSION, '0.30.0')
78
- documentation = Class.new
79
- generator.const_set(:Documentation, documentation)
80
- stub_const('HaveAPI::RustClient', generator)
81
- toml = Module.new
82
- stub_const('Tomlrb', toml)
83
- config_path = File.join(directory, 'generator.toml')
84
- File.write(config_path, 'fixture config')
85
- File.write(description_path, JSON.generate({ api_version: '2' }))
86
- config = { 'crate_name' => 'widget-client', 'crate_version' => '1.2.3' }
87
- allow(toml).to receive(:load_file).with(config_path).and_return(config)
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/&lt;client&gt;&amp;</code></li>')
90
+ expect(last_response.body).not_to include('example.test/<client>', '&amp;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.0
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.0
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.0
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