haveapi 0.30.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/Gemfile +1 -0
  4. data/benchmarks/Gemfile +30 -0
  5. data/benchmarks/Gemfile.lock +296 -0
  6. data/benchmarks/documentation.rb +277 -0
  7. data/benchmarks/endpoints.rb +786 -0
  8. data/benchmarks/performance_budgets.json +1354 -0
  9. data/benchmarks/performance_compare.rb +678 -0
  10. data/benchmarks/shell.nix +21 -0
  11. data/doc/client-examples.md +179 -26
  12. data/doc/performance.md +310 -0
  13. data/haveapi.gemspec +1 -1
  14. data/lib/haveapi/client_examples/ts_generated_client.rb +36 -0
  15. data/lib/haveapi/documentation_clients.rb +49 -6
  16. data/lib/haveapi/documentation_context.rb +42 -12
  17. data/lib/haveapi/version.rb +1 -1
  18. data/lib/haveapi/views/version_page.erb +2 -2
  19. data/spec/client_examples/context_performance_spec.rb +72 -0
  20. data/spec/client_examples/context_spec.rb +206 -0
  21. data/spec/client_examples/profile_mount_spec.rb +45 -0
  22. data/spec/client_examples/ts_generated_client_spec.rb +53 -0
  23. data/spec/client_examples/typescript_profiles_spec.rb +113 -0
  24. data/spec/documentation/client_examples_spec.rb +2 -2
  25. data/spec/documentation/performance_spec.rb +79 -0
  26. data/spec/performance/comparator_spec.rb +472 -0
  27. data/spec/performance/endpoint_contract_spec.rb +226 -0
  28. data/spec/performance/endpoint_scaling_spec.rb +82 -0
  29. data/spec/performance/event_baseline_spec.rb +407 -0
  30. data/spec/performance/json_activation_spec.rb +151 -0
  31. data/spec/performance/release_lineage_spec.rb +470 -0
  32. data/spec/performance/runner_spec.rb +610 -0
  33. data/spec/performance/sqlite_spec.rb +194 -0
  34. data/spec/performance/support/live_worker.rb +44 -0
  35. data/spec/performance/support/samples.rb +72 -0
  36. data/test_support/documentation_performance_fixture.rb +106 -0
  37. data/test_support/performance_endpoint_fixture.rb +350 -0
  38. data/test_support/performance_sqlite_fixture.rb +384 -0
  39. metadata +29 -3
@@ -0,0 +1,36 @@
1
+ require 'haveapi/client_example'
2
+
3
+ module HaveAPI::ClientExamples
4
+ class TsGeneratedClient < HaveAPI::ClientExample
5
+ id 'typescript-generated'
6
+ label 'TypeScript (generated)'
7
+ code :typescript
8
+ order 19
9
+
10
+ def self.available?(context)
11
+ context&.profile&.language == :typescript
12
+ end
13
+
14
+ def init
15
+ render_documentation(:init)
16
+ end
17
+
18
+ def auth(method, desc)
19
+ render_documentation(:auth, auth_method: method, auth_description: desc)
20
+ end
21
+
22
+ def example(sample)
23
+ render_documentation(:example, rendering_context: context&.with(sample:))
24
+ end
25
+
26
+ private
27
+
28
+ def render_documentation(kind, rendering_context: context, **)
29
+ unless self.class.available?(rendering_context)
30
+ return { status: :limitation, reason: 'A generated TypeScript SDK documentation profile is required.' }.freeze
31
+ end
32
+
33
+ rendering_context.profile.adapter.render(kind:, context: rendering_context, mode: :async, **)
34
+ end
35
+ end
36
+ end
@@ -21,7 +21,8 @@ module HaveAPI
21
21
 
22
22
  KEYS = {
23
23
  go: %i[import_path module_version description_path generator_version],
24
- rust: %i[crate_name crate_version source snapshot_path generator_config_path generator_version]
24
+ rust: %i[crate_name crate_version source snapshot_path generator_config_path generator_version],
25
+ typescript: %i[package_name package_version snapshot_path generator_version]
25
26
  }.freeze
26
27
 
27
28
  def initialize
@@ -52,9 +53,12 @@ module HaveAPI
52
53
  raise Invalid, 'Documentation client profile selects an unavailable API version' unless unknown.empty?
53
54
 
54
55
  @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'
56
+ %i[rust typescript].each do |language|
57
+ snapshot = languages.dig(language, :snapshot)
58
+ if snapshot && snapshot.fetch('api_version').to_s != version
59
+ label = language == :rust ? 'Rust' : 'TypeScript'
60
+ raise Invalid, "Documentation #{label} snapshot API version mismatch"
61
+ end
58
62
  end
59
63
  end
60
64
  end
@@ -101,7 +105,7 @@ module HaveAPI
101
105
 
102
106
  def validate_config(language, config)
103
107
  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) }
108
+ config.all? { |key, value| %i[source module_version crate_version package_version].include?(key) || nonempty_string?(value) }
105
109
  raise Invalid, 'Invalid documentation client profile fields'
106
110
  end
107
111
 
@@ -125,7 +129,11 @@ module HaveAPI
125
129
  def load_profile(language, config)
126
130
  safely do
127
131
  config = validate_config(language, config)
128
- language == :go ? load_go(config) : load_rust(config)
132
+ case language
133
+ when :go then load_go(config)
134
+ when :rust then load_rust(config)
135
+ when :typescript then load_typescript(config)
136
+ end
129
137
  end
130
138
  end
131
139
 
@@ -211,6 +219,12 @@ module HaveAPI
211
219
 
212
220
  arguments = { description:, api_version: version, protocol_version: HaveAPI::PROTOCOL_VERSION,
213
221
  mount_path: server.root }
222
+ if language == :typescript
223
+ adapter = HaveAPI::TypeScriptClient::Documentation.from_description(
224
+ **arguments, package_name: config[:name], package_version: config[:version]
225
+ )
226
+ return profile(:typescript, config, adapter)
227
+ end
214
228
  if config[:generator_config]
215
229
  snapshot = HaveAPI::RustClient::Snapshot.from_description(
216
230
  **arguments, source_url: "https://haveapi.invalid#{server.root}v#{version}/", profile: 'server-documentation'
@@ -237,6 +251,35 @@ module HaveAPI
237
251
  profile(:rust, config, adapter)
238
252
  end
239
253
 
254
+ def load_typescript(config)
255
+ name = config[:package_name]
256
+ version = config[:package_version]
257
+ unless nonempty_string?(name) && name.bytesize <= 214 &&
258
+ name.match?(%r{\A(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*\z}) &&
259
+ !%w[node_modules favicon.ico].include?(name) &&
260
+ (version.nil? || (version.is_a?(String) &&
261
+ version.match?(/\A(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z/)))
262
+ raise Invalid, 'Invalid TypeScript documentation client package'
263
+ end
264
+
265
+ require 'haveapi/typescript_client/documentation'
266
+ unless config.fetch(:generator_version) == HaveAPI::TypeScriptClient::VERSION
267
+ raise Invalid, 'Documentation TypeScript generator version mismatch'
268
+ end
269
+
270
+ documentation = HaveAPI::TypeScriptClient::Documentation
271
+ unless documentation.respond_to?(:from_description) && documentation.respond_to?(:from_snapshot)
272
+ raise Invalid, 'Documentation TypeScript generator has an incompatible profile interface'
273
+ end
274
+
275
+ prepared = { name: name.dup.freeze, version: version&.dup&.freeze }.freeze
276
+ return prepared unless config.has_key?(:snapshot_path)
277
+
278
+ snapshot = JSON.parse(File.read(local_file(config[:snapshot_path])))
279
+ adapter = documentation.from_snapshot(snapshot:, package_name: name, package_version: version)
280
+ prepared.merge(snapshot:, profile: profile(:typescript, prepared, adapter)).freeze
281
+ end
282
+
240
283
  def profile(language, config, adapter)
241
284
  unless adapter.respond_to?(:resolve) && adapter.respond_to?(:render)
242
285
  raise Invalid, 'Documentation client generator has an incompatible adapter interface'
@@ -10,26 +10,56 @@ module HaveAPI
10
10
  def initialize(version:, host:, base_url:, version_url:, description:,
11
11
  resource_path: [], resource: nil, action_name: nil, action: nil,
12
12
  sample: nil, profile: nil)
13
- @version = version.to_s.dup.freeze
14
- @host = host.to_s.dup.freeze
15
- @base_url = base_url.to_s.dup.freeze
16
- @version_url = version_url.to_s.dup.freeze
17
- @description = copy(description)
18
- @resource_path = copy(resource_path.map(&:to_s))
19
- @resource = copy(resource)
20
- @action_name = action_name.nil? ? nil : action_name.to_s.dup.freeze
21
- @action = copy(action)
22
- @sample = copy(sample)
23
- @profile = profile
13
+ @version = normalize(:version, version)
14
+ @host = normalize(:host, host)
15
+ @base_url = normalize(:base_url, base_url)
16
+ @version_url = normalize(:version_url, version_url)
17
+ @description = normalize(:description, description)
18
+ @resource_path = normalize(:resource_path, resource_path)
19
+ @resource = normalize(:resource, resource)
20
+ @action_name = normalize(:action_name, action_name)
21
+ @action = normalize(:action, action)
22
+ @sample = normalize(:sample, sample)
23
+ @profile = normalize(:profile, profile)
24
24
  freeze
25
25
  end
26
26
 
27
27
  def with(**changes)
28
- self.class.new(**FIELDS.to_h { |field| [field, public_send(field)] }, **changes)
28
+ unless instance_of?(Context)
29
+ return self.class.new(**FIELDS.to_h { |field| [field, public_send(field)] }, **changes)
30
+ end
31
+
32
+ unknown = changes.keys - FIELDS
33
+ unless unknown.empty?
34
+ raise ArgumentError, "unknown keyword#{'s' if unknown.length > 1}: #{unknown.map(&:inspect).join(', ')}"
35
+ end
36
+
37
+ # Unchanged fields are already owned snapshots; caller-supplied values
38
+ # must still cross the same ownership boundary as initial construction.
39
+ derived = dup
40
+ changes.each do |field, value|
41
+ derived.instance_variable_set("@#{field}", normalize(field, value))
42
+ end
43
+ derived.freeze
29
44
  end
30
45
 
31
46
  private
32
47
 
48
+ def normalize(field, value)
49
+ case field
50
+ when :version, :host, :base_url, :version_url
51
+ value.to_s.dup.freeze
52
+ when :resource_path
53
+ copy(value.map(&:to_s))
54
+ when :action_name
55
+ value.nil? ? nil : value.to_s.dup.freeze
56
+ when :profile
57
+ value
58
+ else
59
+ copy(value)
60
+ end
61
+ end
62
+
33
63
  def copy(value)
34
64
  case value
35
65
  when Hash
@@ -1,4 +1,4 @@
1
1
  module HaveAPI
2
2
  PROTOCOL_VERSION = '2.0'.freeze
3
- VERSION = '0.30.1'.freeze
3
+ VERSION = '0.31.0'.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><% if profile.package_version %> <%= escape_html(profile.package_version) %><% end %></li>
23
+ <li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/<%= escape_html(language) %>" target="_blank" rel="noopener"><%= { go: 'Go', rust: 'Rust (async and blocking)', typescript: 'TypeScript (generated)' }.fetch(language) %></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>
@@ -31,7 +31,7 @@
31
31
 
32
32
  <p>
33
33
  These examples target API v<%= escape_html(@v) %>. The framework and dynamic
34
- clients share HaveAPI package versions. Generated Go and Rust SDKs use their
34
+ clients share HaveAPI package versions. Generated Go, Rust and TypeScript SDKs use their
35
35
  own package versions. Their configured SDKs are expected to match this API.
36
36
  </p>
37
37
 
@@ -0,0 +1,72 @@
1
+ require 'spec_helper'
2
+
3
+ describe HaveAPI::ClientExample::Context, '#with' do
4
+ let(:sample) { { request: { payload: { 'items' => [0, false, '', nil] } } } }
5
+ let(:profile) { Object.new.freeze }
6
+
7
+ def parent_context(resource_count)
8
+ action = { input: { parameters: { payload: { type: 'Custom' } } }, examples: [sample] }
9
+ description = {
10
+ resources: resource_count.times.to_h do |index|
11
+ ["resource#{index}", { description: "Resource #{index}", actions: { 'show' => action } }]
12
+ end
13
+ }
14
+ described_class.new(version: '1', host: 'example.test', base_url: 'http://example.test/benchmark/api/',
15
+ version_url: 'http://example.test/benchmark/api/v1/', description:,
16
+ resource_path: ['resource0'], resource: description[:resources]['resource0'],
17
+ action_name: 'show', action:, sample:)
18
+ end
19
+
20
+ def allocated_derivations(parent, changes, count: 40)
21
+ # Taking the minimum of repeated batches avoids incidental VM bookkeeping
22
+ # spikes. Expectations and fixture construction stay outside this loop.
23
+ Array.new(3) do
24
+ before = GC.stat(:total_allocated_objects)
25
+ count.times { parent.with(**changes) }
26
+ GC.stat(:total_allocated_objects) - before
27
+ end.min
28
+ end
29
+
30
+ it 'shares every unchanged field and owns a different snapshot for a new request' do
31
+ parent = parent_context(8)
32
+ other_request = parent_context(8)
33
+ children = [parent.with(profile:), parent.with(sample:)]
34
+ fields = %i[version host base_url version_url description resource_path resource action_name action sample profile]
35
+
36
+ children.each_with_index do |child, index|
37
+ changed = index == 0 ? :profile : :sample
38
+ (fields - [changed]).each do |field|
39
+ expect(child.public_send(field)).to equal(parent.public_send(field))
40
+ end
41
+ expect(child).to be_frozen
42
+ end
43
+ expect(other_request.description).to eq(parent.description)
44
+ expect(other_request.description).not_to equal(parent.description)
45
+ expect(other_request.description[:resources]).not_to equal(parent.description[:resources])
46
+ replacement = parent.with(description: parent.description)
47
+ expect(replacement.description).to eq(parent.description)
48
+ expect(replacement.description).not_to equal(parent.description)
49
+ end
50
+
51
+ it 'keeps allocations independent of unrelated description size for small changes' do
52
+ small = parent_context(8)
53
+ large = parent_context(128)
54
+ changes = [{ profile: }, { sample: }]
55
+ [small, large].each do |parent|
56
+ changes.each { |change| 80.times { parent.with(**change) } }
57
+ end
58
+
59
+ changes.each do |change|
60
+ small_allocations = allocated_derivations(small, change)
61
+ large_allocations = allocated_derivations(large, change)
62
+ # CRuby 3.4 allocates 200/200 objects for profile changes and 560/560 for
63
+ # samples at these sizes. The original constructor path grows from 12,560
64
+ # to 151,760. Allow 32 extra objects per derivation for VM bookkeeping;
65
+ # identity assertions independently forbid whole-description copies.
66
+ expect(large_allocations).to be <= small_allocations + (40 * 32)
67
+ [small, large].each do |parent|
68
+ expect(parent.with(**change).description).to equal(parent.description)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -25,6 +25,212 @@ describe HaveAPI::ClientExample::Context do
25
25
  expect(child).to be_frozen
26
26
  end
27
27
 
28
+ describe '#with' do
29
+ let(:fields) do
30
+ {
31
+ version: 1, host: 'api.test', base_url: 'https://api.test/mount/',
32
+ version_url: 'https://api.test/mount/v1/',
33
+ description: { 'resources' => { 'widget' => { 'label' => 'Widget' } } },
34
+ resource_path: [:widget, 2], resource: { 'label' => 'Widget' },
35
+ action_name: :show, action: { 'method' => 'GET' },
36
+ sample: { 'request' => { 'values' => [0, false, '', nil] } }, profile: Object.new
37
+ }
38
+ end
39
+ let(:parent) { described_class.new(**fields) }
40
+ let(:replacements) do
41
+ {
42
+ version: 2, host: 'other.test', base_url: 'https://other.test/api/',
43
+ version_url: 'https://other.test/api/v2/',
44
+ description: { 'resources' => { 'other' => { 'label' => 'Other' } } },
45
+ resource_path: [:other, 3], resource: { 'label' => 'Other' },
46
+ action_name: :update, action: { 'method' => 'POST' },
47
+ sample: { 'request' => { 'values' => [false, 0, nil, ''] } },
48
+ profile: Object.new
49
+ }
50
+ end
51
+
52
+ it 'returns a distinct frozen context sharing every unchanged field' do
53
+ child = parent.with
54
+
55
+ expect(child).not_to equal(parent)
56
+ expect(child).to be_frozen
57
+ described_class::FIELDS.each do |field|
58
+ expect(child.public_send(field)).to equal(parent.public_send(field))
59
+ end
60
+ expect(child.description['resources']['widget']).to equal(parent.description['resources']['widget'])
61
+ end
62
+
63
+ HaveAPI::ClientExample::Context::FIELDS.each do |field|
64
+ it "normalizes an explicit #{field} replacement like the constructor and shares other fields" do
65
+ changed = { field => replacements.fetch(field) }
66
+ child = parent.with(**changed)
67
+ constructor = described_class.new(**fields, **changed)
68
+
69
+ expect(child).to be_frozen
70
+ expect(child.public_send(field)).to eq(constructor.public_send(field))
71
+ (described_class::FIELDS - [field]).each do |unchanged|
72
+ expect(child.public_send(unchanged)).to equal(parent.public_send(unchanged))
73
+ end
74
+ expect(parent.public_send(field)).to eq(described_class.new(**fields).public_send(field))
75
+ end
76
+ end
77
+
78
+ it 'copies supplied nested containers and strings even when their outer hash is frozen' do
79
+ key = ['mutable key']
80
+ source = { key => [{ 'value' => 'safe' }] }.freeze
81
+ child = parent.with(description: source, resource: source, action: source, sample: source)
82
+
83
+ %i[description resource action sample].each do |field|
84
+ owned = child.public_send(field)
85
+ expect(owned).not_to equal(source)
86
+ expect(owned).to be_frozen
87
+ expect(owned.keys.first).not_to equal(key)
88
+ expect(owned.keys.first).to be_frozen
89
+ expect(owned.keys.first.first).to be_frozen
90
+ expect(owned.values.first).to be_frozen
91
+ expect(owned.values.first.first).to be_frozen
92
+ expect(owned.values.first.first.keys.first).to be_frozen
93
+ expect(owned.values.first.first['value']).to be_frozen
94
+ end
95
+ expect(key).not_to be_frozen
96
+ expect(key.first).not_to be_frozen
97
+ expect(source.values.first).not_to be_frozen
98
+ expect(source.values.first.first['value']).not_to be_frozen
99
+ key.first.replace('changed key')
100
+ source.values.first.first['value'].replace('changed')
101
+ %i[description resource action sample].each do |field|
102
+ expect(child.public_send(field)).to eq(['mutable key'] => [{ 'value' => 'safe' }])
103
+ end
104
+ end
105
+
106
+ it 'detaches explicitly supplied strings and converts resource path elements without retaining them' do
107
+ text = +'value'
108
+ path = [text, :widget, 7]
109
+ child = parent.with(version: text, host: text, base_url: text, version_url: text,
110
+ resource_path: path, action_name: text)
111
+
112
+ %i[version host base_url version_url action_name].each do |field|
113
+ expect(child.public_send(field)).to eq('value')
114
+ expect(child.public_send(field)).not_to equal(text)
115
+ expect(child.public_send(field)).to be_frozen
116
+ end
117
+ expect(child.resource_path).to eq(%w[value widget 7])
118
+ expect(child.resource_path).to be_frozen
119
+ expect(child.resource_path).not_to equal(path)
120
+ expect(child.resource_path).to all(be_frozen)
121
+ expect(path).not_to be_frozen
122
+ expect(text).not_to be_frozen
123
+ text.replace('changed')
124
+ path << 'extra'
125
+ expect(child.version).to eq('value')
126
+ expect(child.resource_path).to eq(%w[value widget 7])
127
+ end
128
+
129
+ it 'copies explicitly supplied owned values again instead of treating frozen data as trusted' do
130
+ child = parent.with(description: parent.description, resource_path: parent.resource_path,
131
+ sample: parent.sample)
132
+
133
+ %i[description resource_path sample].each do |field|
134
+ expect(child.public_send(field)).to eq(parent.public_send(field))
135
+ expect(child.public_send(field)).not_to equal(parent.public_send(field))
136
+ end
137
+ expect(child.description['resources']).not_to equal(parent.description['resources'])
138
+ expect(child.sample['request']['values']).not_to equal(parent.sample['request']['values'])
139
+ end
140
+
141
+ it 'keeps parent, sibling and chained derivations detached from their supplied inputs' do
142
+ data = { request: { name: 'safe' } }
143
+ child = parent.with(sample: data)
144
+ sibling = parent.with(sample: data)
145
+ grandchild = child.with(action_name: :update)
146
+ fields[:description]['resources']['widget']['label'].replace('changed source')
147
+ data[:request][:name].replace('changed sample')
148
+
149
+ expect(parent.description['resources']['widget']['label']).to eq('Widget')
150
+ expect(child.description).to equal(parent.description)
151
+ expect(sibling.description).to equal(parent.description)
152
+ expect(grandchild.description).to equal(parent.description)
153
+ expect(parent.sample).to eq('request' => { 'values' => [0, false, '', nil] })
154
+ expect(child.sample).to eq(request: { name: 'safe' })
155
+ expect(sibling.sample).to eq(child.sample)
156
+ expect(sibling.sample).not_to equal(child.sample)
157
+ expect(grandchild.sample).to equal(child.sample)
158
+ expect(child.action_name).to eq('show')
159
+ expect(grandchild.action_name).to eq('update')
160
+ expect { child.sample[:request][:name].replace('forbidden') }.to raise_error(FrozenError)
161
+ end
162
+
163
+ it 'distinguishes omitted fields from explicit nil replacements' do
164
+ child = parent.with(description: nil, resource: nil, action_name: nil, action: nil, sample: nil, profile: nil)
165
+
166
+ %i[description resource action_name action sample profile].each do |field|
167
+ expect(child.public_send(field)).to be_nil
168
+ expect(parent.public_send(field)).not_to be_nil
169
+ end
170
+ expect(child.version).to equal(parent.version)
171
+ expect(parent.with(version: nil, host: nil, base_url: nil, version_url: nil))
172
+ .to have_attributes(version: '', host: '', base_url: '', version_url: '')
173
+ expect(context.with.action_name).to be_nil
174
+ end
175
+
176
+ it 'shares arbitrary profiles and non-container leaves without freezing them' do
177
+ leaf = Object.new
178
+ profile = fields.fetch(:profile)
179
+ child = parent.with(description: { custom: leaf }, profile: leaf)
180
+
181
+ expect(parent.profile).to equal(profile)
182
+ expect(parent.with.profile).to equal(profile)
183
+ expect(profile).not_to be_frozen
184
+ expect(child.profile).to equal(leaf)
185
+ expect(child.description[:custom]).to equal(leaf)
186
+ expect(leaf).not_to be_frozen
187
+ end
188
+
189
+ it 'continues rejecting invalid resource paths' do
190
+ expect { parent.with(resource_path: nil) }.to raise_error(NoMethodError)
191
+ expect { described_class.new(**fields, resource_path: nil) }.to raise_error(NoMethodError)
192
+ expect(parent.resource_path).to eq(%w[widget 2])
193
+ end
194
+
195
+ it 'rejects unknown keywords before normalizing any supplied values' do
196
+ value = Object.new
197
+ allow(value).to receive(:to_s).and_call_original
198
+
199
+ expect { parent.with(version: value, unknown: true) }.to raise_error(ArgumentError, 'unknown keyword: :unknown')
200
+ expect { parent.with(unknown: true, other: false) }
201
+ .to raise_error(ArgumentError, 'unknown keywords: :unknown, :other')
202
+ expect { parent.with('version' => '2') }.to raise_error(ArgumentError, 'unknown keyword: "version"')
203
+ expect(value).not_to have_received(:to_s)
204
+ expect(parent).not_to respond_to(:unknown)
205
+ expect(parent.version).to eq('1')
206
+ end
207
+
208
+ it 'keeps subclass constructors, extra keywords and constructor effects on every derivation' do
209
+ subclass = Class.new(described_class) do
210
+ attr_reader :marker, :initializations
211
+
212
+ def initialize(marker: :default, initializations: 0, **fields)
213
+ @marker = marker
214
+ @initializations = initializations + 1
215
+ super(**fields)
216
+ end
217
+ end
218
+ original = subclass.new(**fields, marker: :original)
219
+ child = original.with(marker: :child, initializations: original.initializations)
220
+ defaulted = original.with
221
+
222
+ expect(child).to be_a(subclass)
223
+ expect(child).to be_frozen
224
+ expect(child.marker).to eq(:child)
225
+ expect(child.initializations).to eq(2)
226
+ expect(defaulted.marker).to eq(:default)
227
+ expect(original.marker).to eq(:original)
228
+ expect(original.initializations).to eq(1)
229
+ expect(child.description).to eq(original.description)
230
+ expect(child.description).not_to equal(original.description)
231
+ end
232
+ end
233
+
28
234
  it 'preserves legacy initialization constructors and wraps plain source' do
29
235
  renderer = Class.new(HaveAPI::ClientExample) do
30
236
  def initialize(host, base_url, version)
@@ -139,6 +139,20 @@ describe HaveAPI::Server, '#mount' do
139
139
  )
140
140
  end
141
141
 
142
+ def register_typescript
143
+ generator = Module.new
144
+ generator.const_set(:VERSION, HaveAPI::VERSION)
145
+ generator.const_set(:Documentation, documentation)
146
+ stub_const('HaveAPI::TypeScriptClient', generator)
147
+ allow(registry).to receive(:require).with('haveapi/typescript_client/documentation').and_return(true)
148
+ allow(documentation).to receive(:from_description) do |description:, **|
149
+ descriptions << description
150
+ adapter
151
+ end
152
+ allow(documentation).to receive(:from_snapshot).and_return(adapter)
153
+ server.documentation_clients(1, typescript: { package_name: '@example/widget-client' })
154
+ end
155
+
142
156
  it 'captures the complete selected version after auth, action-state and post-mount routes exist' do
143
157
  register
144
158
  server.action_state = Object.new
@@ -212,4 +226,35 @@ describe HaveAPI::Server, '#mount' do
212
226
  expect(server.app).to be_nil
213
227
  expect(server.documentation_client_profiles(1)).to be_empty
214
228
  end
229
+
230
+ it 'mounts a generated TypeScript panel beside dynamic tabs using only authorized request samples' do
231
+ register_typescript
232
+ server.mount('/mounted/api/')
233
+ expect(descriptions.size).to eq(1)
234
+ private_schema = descriptions.first
235
+ expect(private_schema).to be_frozen
236
+ expect(JSON.generate(private_schema)).not_to include('PRIVATE_DEFAULT', 'PRIVATE_SAMPLE', 'PRIVATE_RESPONSE')
237
+ expect(private_schema.dig('resources', 'widget', 'actions')).to have_key('secret')
238
+ expect(request_as('admin', 'en').body).to include('PRIVATE_SAMPLE')
239
+ contexts.clear
240
+ response = request_as('member', 'en')
241
+ expect(response.status).to eq(200)
242
+ expect(response.body).to include('TypeScript (generated)', 'init-typescript-generated', 'init-typescript-callback',
243
+ 'init-typescript-async', 'example-widget-show-0-typescript-generated', '@example/widget-client')
244
+ expect(response.body).not_to include('PRIVATE_DEFAULT', 'PRIVATE_SAMPLE', 'PRIVATE_RESPONSE', 'admin_only')
245
+ selected = contexts.find { |context| context.action_name == 'show' }
246
+ expect(selected.version_url).to eq('http://example.org/mounted/api/v1/')
247
+ expect(selected.action.dig(:input, :parameters).keys).to eq([:visible])
248
+ expect(selected.sample[:request]).to eq(visible: 'public sample')
249
+ expect(documentation).to have_received(:from_description).once
250
+ end
251
+
252
+ it 'aborts the whole mount when the generated TypeScript adapter rejects the full declaration' do
253
+ register_typescript
254
+ allow(documentation).to receive(:from_description).and_raise('PRIVATE_TYPESCRIPT_DIAGNOSTIC')
255
+ expect { server.mount('/') }.to raise_error(HaveAPI::ClientExample::Profiles::Invalid,
256
+ 'Configured documentation client schema or configuration is invalid')
257
+ expect(server.app).to be_nil
258
+ expect(server.documentation_client_profiles(1)).to be_empty
259
+ end
215
260
  end
@@ -0,0 +1,53 @@
1
+ require 'spec_helper'
2
+
3
+ class TypeScriptDocumentationAdapterFixture
4
+ def render(kind:, context:, mode:, auth_method: nil, auth_description: nil); end
5
+ end
6
+
7
+ describe HaveAPI::ClientExamples::TsGeneratedClient do
8
+ let(:adapter) { instance_spy(TypeScriptDocumentationAdapterFixture) }
9
+ let(:profile) { Struct.new(:language, :adapter).new(:typescript, adapter).freeze }
10
+ let(:context) do
11
+ HaveAPI::ClientExample::Context.new(
12
+ version: '1.0', host: 'example.test', base_url: 'https://example.test/mount/',
13
+ version_url: 'https://example.test/mount/v1.0/', description: {},
14
+ resource_path: ['widget'], action_name: 'update', action: {}, sample: { request: {} }, profile:
15
+ )
16
+ end
17
+
18
+ it 'adds a separate generated variant while preserving dynamic TypeScript identifiers' do
19
+ expect(described_class.id).to eq('typescript-generated')
20
+ expect(described_class.label).to eq('TypeScript (generated)')
21
+ expect(described_class.code).to eq(:typescript)
22
+ expect(HaveAPI::ClientExamples::TsClient.id).to eq('typescript-callback')
23
+ expect(HaveAPI::ClientExamples::TsAsyncClient.id).to eq('typescript-async')
24
+ end
25
+
26
+ it 'is available only for a matching optional profile' do
27
+ expect(described_class.available?(nil)).to be(false)
28
+ expect(described_class.available?(context.with(profile: nil))).to be(false)
29
+ expect(described_class.available?(context.with(profile: Struct.new(:language).new(:rust)))).to be(false)
30
+ expect(described_class.available?(context)).to be(true)
31
+ end
32
+
33
+ it 'delegates the immutable authorized context and exact authentication metadata' do
34
+ result = { status: :code, code: 'generated TypeScript' }.freeze
35
+ allow(adapter).to receive(:render).and_return(result)
36
+ expect(described_class.render(:init, context:)).to eq(result)
37
+ expect(described_class.render(:auth, context:, auth_method: :token, auth_description: { http_header: 'X-Token' })).to eq(result)
38
+ expect(adapter).to have_received(:render).with(kind: :init, context:, mode: :async)
39
+ expect(adapter).to have_received(:render).with(kind: :auth, context:, mode: :async, auth_method: :token,
40
+ auth_description: { http_header: 'X-Token' })
41
+ end
42
+
43
+ it 'binds only the supplied authorized sample and preserves limitations' do
44
+ sample = { request: { label: 'public' } }
45
+ result = { status: :limitation, reason: 'The saved SDK does not represent this sample.' }.freeze
46
+ allow(adapter).to receive(:render).and_return(result)
47
+ renderer = described_class.new('example.test', 'https://example.test/', '1.0').with_context(context)
48
+ expect(renderer.example(sample)).to eq(result)
49
+ expect(adapter).to have_received(:render).with(kind: :example, mode: :async, context: have_attributes(sample: sample))
50
+ expect(renderer.init).to eq(result)
51
+ expect(described_class.new('example.test', 'https://example.test/', '1.0').init).to include(status: :limitation)
52
+ end
53
+ end