haveapi 0.30.2 → 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.
data/doc/performance.md CHANGED
@@ -15,39 +15,45 @@ every future performance characteristic.
15
15
 
16
16
  ## Local commands
17
17
 
18
- Use the repository's Nix environments and prepare dependencies before running
19
- measurements. `nix develop .#server-ruby` prepares the server bundle; the top-level
20
- `nix develop` prepares contributor tools. Both profiles render full Rust-enabled
21
- pages and need the Ruby dependencies from `nix develop .#client-rust`. SDK
22
- generation, Cargo and formatting are outside these measurements. Resolved
23
- component lockfiles remain local library inputs and must not be committed.
24
-
25
- Run deterministic specs in the prepared top-level shell:
18
+ The benchmark has its own third-party tool bundle and pinned Nix shell in
19
+ `servers/ruby/benchmarks/`. This keeps contributor Nix updates separate from the
20
+ calibrated measurement runtime. Prepare the ordinary frozen bundle once:
26
21
 
27
22
  ```sh
28
- make test-performance
23
+ export HAVEAPI_BENCHMARK_GEM_HOME="$PWD/servers/ruby/benchmarks/.gems"
24
+ nix-shell servers/ruby/benchmarks/shell.nix --run '
25
+ set -e
26
+ gem install bundler --version 4.0.23 --no-document
27
+ BUNDLE_IGNORE_CONFIG=1 BUNDLE_FROZEN=1 BUNDLE_PATH__SYSTEM=1 \
28
+ BUNDLE_GEMFILE="$PWD/servers/ruby/benchmarks/Gemfile" bundle _4.0.23_ install
29
+ '
29
30
  ```
30
31
 
31
- The runner consumes the same neutral fixtures for both implementation roots,
32
- using a fresh process for each sample. Keep the harness in a candidate repository
33
- checkout containing the exact baseline and candidate Git objects. Export both
34
- source revisions separately, retain their archive hashes and source manifests,
35
- and run `make doc` in each exported root. Do not point one process at both roots.
36
- Then run outside Bundler with the prepared gem stores:
32
+ The tool Gemfile installs no HaveAPI package. Its tracked lock is an application
33
+ lock; component library locks remain local contributor inputs. Launch the runner
34
+ outside Bundler so both exported sources use the same Ruby and gem paths. The
35
+ shell clears inherited Bundler settings, `RUBYOPT`, `RUBYLIB` and
36
+ `RUBYGEMS_GEMDEPS`.
37
+
38
+ Run deterministic specs in the prepared contributor shell with
39
+ `make test-performance`. For a comparison, keep the neutral harness in a candidate
40
+ checkout containing both exact Git objects. Export the baseline and candidate
41
+ separately, retain their archive hashes and manifests, and run `make doc` in each
42
+ exported root. Then run from the candidate checkout:
37
43
 
38
44
  ```sh
39
- env -u RUBYOPT -u RUBYLIB -u BUNDLE_BIN_PATH -u BUNDLE_GEMFILE \
40
- -u BUNDLE_LOCKFILE -u BUNDLE_PATH -u BUNDLE_FROZEN -u BUNDLER_SETUP \
41
- GEM_PATH="$PWD/.gems:$PWD/clients/rust/.gems:$(ruby -e 'puts Gem.default_dir')" \
42
- make performance PERFORMANCE_ARGS='--baseline-root /absolute/baseline --baseline-sha BASELINE_FULL_SHA --candidate-root /absolute/candidate --candidate-sha CANDIDATE_FULL_SHA --profile quick --budgets servers/ruby/benchmarks/performance_budgets.json --output /absolute/new-evidence-directory'
45
+ nix-shell servers/ruby/benchmarks/shell.nix --run '
46
+ make performance PERFORMANCE_ARGS="--baseline-root /absolute/baseline --baseline-sha BASELINE_FULL_SHA --candidate-root /absolute/candidate --candidate-sha CANDIDATE_FULL_SHA --profile quick --budgets servers/ruby/benchmarks/performance_budgets.json --output /absolute/new-evidence-directory"
47
+ '
43
48
  ```
44
49
 
45
- Run inside the prepared top-level Nix shell, from the candidate checkout.
46
- Replace the paths and full 40-character lowercase SHAs. Use `--profile weekly`
47
- for the expanded workload. The runner verifies source paths, source identities,
48
- response contracts and dependency/runtime identities before accepting a result.
49
- An incompatible graph or fixture contract fails; prepare a separately reviewed
50
- environment/reference migration instead of mixing versions or relaxing checks.
50
+ Replace paths and full 40-character lowercase SHAs. Use `--profile weekly` for
51
+ the expanded workload. The runner checks the exact calibrated Ruby and available
52
+ dependency versions before timing, then requires each worker's complete activated
53
+ graph to match the weekly calibration. Source and loaded-file checks and
54
+ response validation must also pass. An unsupported declaration or runtime change
55
+ requires a separately reviewed environment migration; it does not update the
56
+ reference or budgets.
51
57
 
52
58
  To reclassify retained samples without making requests:
53
59
 
@@ -59,14 +65,22 @@ make performance-compare \
59
65
  The commands invoke `servers/ruby/benchmarks/endpoints.rb run` and `compare`
60
66
  directly. `run` retains `samples.json`, `report.json` and process diagnostics.
61
67
  `compare` reads compact samples format version 2; unsupported versions are
62
- rejected without conversion. Historical evidence remains attributable to its
63
- recorded implementation. `report.json` contains source/profile identity,
64
- overall outcome and per-workload results, without raw samples or response bodies.
68
+ rejected without conversion. `report.json` retains the original measurement's
69
+ `harness_sha256`. Its `comparison` metadata identifies policy
70
+ `per-revision-response-stability-v1`, the loaded comparator file's SHA-256 and
71
+ the SHA-256 of the exact consumed `samples.json` bytes. Error reports retain
72
+ these identities when available; they omit unavailable measurement/input fields
73
+ instead of identifying old measurements with the current harness. The existing
74
+ `budget_sha256` identifies the budget. Replaying samples classifies the recorded
75
+ measurements under the identified comparison policy. It makes no requests and
76
+ leaves the original result unchanged.
77
+ `report.json` contains source/profile identity, overall outcome and per-workload
78
+ results, without raw samples or response bodies.
65
79
  Exit codes are 0 for a pass
66
80
  or an inconclusive timing result, 1 for a regression and 3 for errors, including
67
- missing workloads, wrong responses, inputs or provenance. An inconclusive report
68
- remains explicitly inconclusive; a successful process exit does not establish a
69
- measured performance pass. Deterministic failures and regressions block CI.
81
+ missing workloads, invalid or unstable responses, inputs or provenance. An
82
+ inconclusive report remains explicitly inconclusive; a successful process exit
83
+ does not establish a measured performance pass. Deterministic failures and regressions block CI.
70
84
  Keep the original evidence and diagnose failures or insufficient timing signal;
71
85
  there is no automatic retry loop or baseline update.
72
86
 
@@ -140,10 +154,24 @@ Samples retain metrics, one uniform validated counter record with its operation
140
154
  count, and distinct response examples with occurrence counts and raw bodies.
141
155
  Replay checks those counts, counters, examples and raw/canonical identities,
142
156
  including comparison mode, verified own version and source/environment/inputs.
143
- It compares response occurrence totals across paired samples; it does not retain
144
- request order or independently reprove each live authorization/counter check.
157
+ It compares canonical response identities and occurrence totals across samples
158
+ from the same verified source SHA. Both columns of a baseline/self pair belong
159
+ to the baseline SHA. JSON map order may vary; raw byte stability is not required.
160
+ It does not retain request order or independently reprove each live
161
+ authorization/counter check.
145
162
  The compact artifact is a summary, not an execution transcript.
146
163
 
164
+ Responses may differ between baseline and candidate without failing or skipping
165
+ the performance comparison. Each workload reports `raw_changed` and
166
+ `canonical_changed`, together with baseline/candidate bytes per request and
167
+ their byte and percentage changes. These diagnostics use the
168
+ occurrence-weighted output distributions from baseline/candidate pairs only;
169
+ baseline/self pairs do not affect the size comparison. Reports retain no raw
170
+ bodies. Response growth alone does not fail the comparison or change its timing
171
+ and allocation limits. Equal response lengths do not prove correctness. The CI
172
+ step summary shows these diagnostics beside wall, CPU, allocation and overall
173
+ outcomes, including regressions and inconclusive timing.
174
+
147
175
  Allocation growth above baseline median plus the larger of 10% or 32 objects
148
176
  per request fails. Additional fixed-reference ceilings use measured accepted
149
177
  counts with 25% headroom plus 32 objects/request. Exact SQL query and required
@@ -153,10 +181,11 @@ a constant amount. Budget changes require measured justification and review.
153
181
  Missing or uncalibrated budgets for the existing calibrated workloads fail rather
154
182
  than disabling the gate.
155
183
 
156
- Both profiles use the already recorded weekly Rust dependency closure as their
157
- expected runtime identity. Quick includes the Rust manifests, gemspec, resolved
158
- lock and actually loaded dependencies too. Baseline and candidate must match
159
- that environment exactly; unavailable or changed identities fail. The shared
184
+ Both profiles use the recorded weekly Rust dependency closure as their expected
185
+ runtime identity. Quick includes the Rust manifests, gemspec and loaded
186
+ dependencies. Both profiles retain the benchmark Gemfile, lock and shell bytes
187
+ and hashes. Baseline and candidate must match that environment exactly;
188
+ unavailable or changed identities fail. The shared
160
189
  comparison entry point activates the recorded native JSON 2.21.2 before its
161
190
  first require, including in unbundled workers. A newer visible or already loaded
162
191
  JSON must not silently replace that dependency; a missing or conflicting version
@@ -183,20 +212,23 @@ Missing event/source evidence fails even when no relevant path can be establishe
183
212
  | Event | Candidate | Baseline |
184
213
  | --- | --- | --- |
185
214
  | Pull request | Exact event head SHA | Merge-base of head and exact event base SHA |
186
- | Feature push, including a new branch | Event `after` | Merge-base with captured default-branch SHA; compares the whole feature |
187
- | Default-branch push | Event `after` | Event `before`, covering the whole pushed batch |
188
- | `haveapi-0.30` push with verified maintenance lineage | Event `after` | Event `before`, covering the whole pushed batch |
189
- | Feature push with verified maintenance lineage | Event `after` | Merge-base with captured `origin/haveapi-0.30` head |
215
+ | Existing branch push, quick | Exact event `after` | Exact nonzero event `before`, on every branch |
216
+ | New branch push, quick (zero `before`) | Exact event `after` | Merge-base with captured exact default-branch SHA |
190
217
  | Weekly | Exact scheduled default-branch event SHA | Fixed accepted reference below |
191
218
  | Manual with a complete explicit pair | Supplied full candidate SHA | Supplied full baseline SHA |
192
219
  | Manual without either SHA | Captured default SHA | Fixed accepted reference below |
193
220
 
194
221
  The immutable accepted reference is
195
222
  `b85ec364057f536940d689b09e612512ffffaf82`. Required ancestry is checked. The
196
- reference and budgets never move automatically. Deleted branches, initial
197
- default pushes without a predecessor, missing commits, partial manual pairs,
198
- unrelated history or equal baseline/candidate fail. PRs use the head commit,
199
- never the synthetic merge commit; default pushes use `before`, never `HEAD^`.
223
+ reference and budgets never move automatically. Deleted branches, malformed or
224
+ missing commits, unavailable new-branch fallback, partial manual pairs and equal
225
+ endpoints fail. PRs use the head commit, never the synthetic merge commit. An
226
+ existing branch push uses `before`, never `HEAD^` or a guessed fallback. A quick
227
+ push can compare a nonancestor before/after pair after a force push; its exact
228
+ event pair is carried through all ancestry guards. Direct CLI use of that
229
+ exception requires `--push-event selection.json` from the event resolver. Other
230
+ PR, manual and weekly ancestry restrictions remain. Relevance uses the direct
231
+ before/after tree diff, including the benchmark setup files.
200
232
  The weekly profile always requires that fixed baseline, including an explicit
201
233
  manual pair. An explicit quick pair may select another ancestor.
202
234
 
@@ -217,29 +249,35 @@ Diff rendering uses full blob IDs and explicit formatting options, independent
217
249
  of repository-dependent abbreviation and user diff display settings. Ordinary
218
250
  master ancestry is checked before probing the maintenance mapping, so a master
219
251
  clone does not need maintenance objects for that path.
220
- Unrelated quick baselines still fail. Maintenance feature selection records both
221
- the captured default and release heads; missing release refs fail closed.
222
-
223
- These verified maintenance comparisons reuse the candidate's prepared environment
224
- for both sources. In addition to the JSON preparation rule above, they normalize
225
- the shared `0.30.x` patch identity, matching
226
- local Ruby component VERSION assignments and exactly synchronized internal
227
- `haveapi-client` requirements. All remaining bytes in dependency manifests, gemspecs,
228
- Ruby requirements and flake inputs must match, as must the resolved third-party
229
- runtime graph and retained lock bytes. Raw manifest contents and hashes remain
230
- bound to each verified source tree alongside their normalized hashes. Replay
252
+ Unrelated direct quick baselines still fail. Existing maintenance branch pushes
253
+ use their exact pushed pair; new branches retain the captured-default fallback
254
+ and reason. A `haveapi-0.30` push still requires verified maintenance lineage.
255
+
256
+ Both sources in a maintenance comparison use the prepared benchmark environment.
257
+ In addition to the JSON preparation rule above, these comparisons normalize the
258
+ shared `0.30.x` patch identity, matching local Ruby component VERSION assignments
259
+ and exactly synchronized internal `haveapi-client` requirements. All remaining
260
+ bytes in dependency manifests, gemspecs and Ruby requirements must match, as must
261
+ the resolved third-party runtime graph and benchmark lock bytes. Source `flake.nix` and `flake.lock` remain raw provenance
262
+ with source hashes; they do not supply the benchmark runtime and may differ.
263
+ Every other supported declaration remains strict. The consumed benchmark shell
264
+ and lock are retained separately and must match across workers. Raw manifest
265
+ contents and hashes remain bound to each verified source tree alongside their
266
+ normalized hashes. Replay
231
267
  recomputes the transformation and rejects missing, inconsistent or changed inputs.
232
268
 
233
269
  The HTML comparison may normalize the own-version segment of the documentation
234
270
  templates' `https://webui.haveapi.org/v<own-version>/#...` anchor hrefs and
235
271
  the version in the exact framework-generated HaveAPI footer. The footer must
236
272
  occur once and match the verified source's own VERSION; literal examples, other
237
- footer text and URLs remain exact. Validation runs after the timed loop. Complete raw bodies, byte counts and hashes remain in
238
- the samples, together with the normalized comparison digest and source version.
239
- Replay checks these again from the raw body. The exception applies only to the
240
- HTML documentation workloads on the proven maintenance lineage. JSON/API
273
+ footer text and URLs remain exact. Validation runs after the timed loop. Complete
274
+ raw bodies, byte counts and hashes remain in the samples, together with the
275
+ normalized comparison digest and source version. Replay checks these again from
276
+ the raw body. This normalization applies only to the HTML documentation workloads
277
+ on the proven maintenance lineage. JSON/API
241
278
  values, other URLs and hosts, example code, authorization markers, panels and all
242
- other output remain exact. A version change cannot hide a response difference.
279
+ other output are not normalized. Cross-revision differences remain diagnostic;
280
+ within-revision stability and raw/canonical integrity still apply.
243
281
 
244
282
  The workflow uses ephemeral GitHub-hosted Ubuntu workers, `contents: read`,
245
283
  disabled checkout credential persistence, ordinary `pull_request` events and no
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.2'
18
+ s.add_dependency 'haveapi-client', '~> 0.31.0'
19
19
  s.add_dependency 'i18n', '>= 1.6', '< 2'
20
20
  s.add_dependency 'json'
21
21
  s.add_dependency 'mail'
@@ -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'
@@ -1,4 +1,4 @@
1
1
  module HaveAPI
2
2
  PROTOCOL_VERSION = '2.0'.freeze
3
- VERSION = '0.30.2'.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
 
@@ -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
@@ -0,0 +1,113 @@
1
+ require 'spec_helper'
2
+ require 'tmpdir'
3
+
4
+ describe HaveAPI::ClientExample::Profiles do
5
+ let(:profiles) { described_class.new }
6
+ let(:server) { instance_double(HaveAPI::Server, versions: ['1.0'], root: '/mounted/', describe_version: description) }
7
+ let(:adapter) do
8
+ Class.new do
9
+ def resolve(**); end
10
+ def render(**); end
11
+ end.new
12
+ end
13
+ let(:documentation) { Class.new }
14
+ let(:directory) { Dir.mktmpdir('haveapi-typescript-profile-') }
15
+
16
+ def snapshot_path
17
+ File.join(directory, 'snapshot.json')
18
+ end
19
+
20
+ def description
21
+ { resources: { widget: { actions: { show: { input: { parameters: { hidden: { default: 'PRIVATE_DEFAULT' } } }, examples: ['PRIVATE_SAMPLE'] } } } },
22
+ authentication: {}, meta: { namespace: '_meta' } }
23
+ end
24
+
25
+ before do
26
+ generator = Module.new
27
+ generator.const_set(:VERSION, HaveAPI::VERSION)
28
+ generator.const_set(:Documentation, documentation)
29
+ stub_const('HaveAPI::TypeScriptClient', generator)
30
+ allow(profiles).to receive(:require).with('haveapi/typescript_client/documentation').and_return(true)
31
+ allow(documentation).to receive_messages(from_description: adapter, from_snapshot: adapter)
32
+ end
33
+
34
+ after { FileUtils.remove_entry(directory) }
35
+
36
+ def mount
37
+ profiles.mount(['1.0'])
38
+ profiles.finalize(server)
39
+ end
40
+
41
+ it 'defers one frozen private structural description and preserves omitted package version' do
42
+ config = { package_name: '@example/widget-client' }
43
+ profiles.register('1.0', typescript: config)
44
+ config[:package_name].replace('changed-client')
45
+ expect(documentation).not_to have_received(:from_description)
46
+ mount
47
+ expect(documentation).to have_received(:from_description).once do |**arguments|
48
+ expect(arguments).to include(api_version: '1.0', protocol_version: HaveAPI::PROTOCOL_VERSION, mount_path: '/mounted/',
49
+ package_name: '@example/widget-client', package_version: nil)
50
+ expect(arguments[:description]).to be_frozen
51
+ expect(JSON.generate(arguments[:description])).not_to include('PRIVATE_DEFAULT', 'PRIVATE_SAMPLE')
52
+ expect(arguments[:description].dig('resources', 'widget', 'actions', 'show', 'input', 'parameters')).to have_key('hidden')
53
+ end
54
+ profile = profiles.for_version('1.0').fetch(:typescript)
55
+ expect(profile).to have_attributes(language: :typescript, package_name: '@example/widget-client', package_version: nil)
56
+ expect(profile).to be_frozen
57
+ expect(profile.adapter).to be_frozen
58
+ expect(server).to have_received(:describe_version).once
59
+ end
60
+
61
+ it 'loads a saved SDK once at registration with explicit package and generator versions' do
62
+ File.write(snapshot_path, JSON.generate(api_version: '1.0'))
63
+ profiles.register('1.0', typescript: { package_name: 'widget-client', package_version: '1.2.3-beta.1+build.4',
64
+ snapshot_path:, generator_version: HaveAPI::VERSION })
65
+ File.unlink(snapshot_path)
66
+ mount
67
+ expect(documentation).to have_received(:from_snapshot).once.with(snapshot: { 'api_version' => '1.0' },
68
+ package_name: 'widget-client', package_version: '1.2.3-beta.1+build.4')
69
+ expect(server).not_to have_received(:describe_version)
70
+ end
71
+
72
+ it 'rejects unsafe names, nonconcrete versions and unexpected fields before loading the generator' do
73
+ ['../client', '@scope', 'https://example.test/client', 'UPPERCASE', 'client name', 'node_modules'].each do |name|
74
+ expect { profiles.register('1.0', typescript: { package_name: name }) }.to raise_error(described_class::Invalid, /package/)
75
+ end
76
+ ['latest', '^1.2.3', '1.2', '01.2.3', '1.2.3-01', false].each do |version|
77
+ expect { profiles.register('1.0', typescript: { package_name: 'client', package_version: version }) }.to raise_error(described_class::Invalid, /package/)
78
+ end
79
+ expect { profiles.register('1.0', typescript: { package_name: 'client', factory: 'custom' }) }.to raise_error(described_class::Invalid, /fields/)
80
+ expect(profiles).not_to have_received(:require)
81
+ end
82
+
83
+ it 'rejects missing generators, version mismatch, missing snapshots and incompatible factories' do
84
+ allow(profiles).to receive(:require).and_raise(LoadError)
85
+ expect { profiles.register('1.0', typescript: { package_name: 'client' }) }.to raise_error(described_class::Invalid, /not installed/)
86
+ allow(profiles).to receive(:require).and_return(true)
87
+ expect { profiles.register('1.0', typescript: { package_name: 'client', generator_version: '0.0.0' }) }.to raise_error(described_class::Invalid, /version mismatch/)
88
+ expect { profiles.register('1.0', typescript: { package_name: 'client', snapshot_path: '/missing/schema.json' }) }.to raise_error(described_class::Invalid, /absolute local/)
89
+ allow(documentation).to receive(:respond_to?).and_call_original
90
+ allow(documentation).to receive(:respond_to?).with(:from_snapshot).and_return(false)
91
+ expect { profiles.register('1.0', typescript: { package_name: 'client' }) }.to raise_error(described_class::Invalid, /incompatible profile/)
92
+ end
93
+
94
+ it 'rejects a different saved API version without publishing any profile' do
95
+ File.write(snapshot_path, JSON.generate(api_version: '2.0'))
96
+ profiles.register('1.0', typescript: { package_name: 'client', snapshot_path: })
97
+ expect { profiles.mount(['1.0']) }.to raise_error(described_class::Invalid, /TypeScript snapshot API version mismatch/)
98
+ expect(profiles.for_version('1.0')).to be_empty
99
+ end
100
+
101
+ it 'fails atomically and hides private diagnostics for invalid schemas or adapters' do
102
+ allow(documentation).to receive(:from_description).and_raise('PRIVATE_SCHEMA_DIAGNOSTIC')
103
+ profiles.register('1.0', typescript: { package_name: 'client' })
104
+ expect { mount }.to raise_error(described_class::Invalid, 'Configured documentation client schema or configuration is invalid')
105
+ expect(profiles.for_version('1.0')).to be_empty
106
+ end
107
+
108
+ it 'rejects an adapter missing render or resolve during initialization' do
109
+ allow(documentation).to receive(:from_description).and_return(Object.new)
110
+ profiles.register('1.0', typescript: { package_name: 'client' })
111
+ expect { mount }.to raise_error(described_class::Invalid, /adapter interface/)
112
+ end
113
+ end