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.
- checksums.yaml +4 -4
- data/benchmarks/Gemfile +30 -0
- data/benchmarks/Gemfile.lock +296 -0
- data/benchmarks/endpoints.rb +70 -14
- data/benchmarks/performance_compare.rb +112 -13
- data/benchmarks/shell.nix +21 -0
- data/doc/client-examples.md +81 -35
- data/doc/performance.md +99 -61
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/client_examples/ts_generated_client.rb +36 -0
- data/lib/haveapi/documentation_clients.rb +49 -6
- data/lib/haveapi/version.rb +1 -1
- data/lib/haveapi/views/version_page.erb +2 -2
- data/spec/client_examples/profile_mount_spec.rb +45 -0
- data/spec/client_examples/ts_generated_client_spec.rb +53 -0
- data/spec/client_examples/typescript_profiles_spec.rb +113 -0
- data/spec/documentation/client_examples_spec.rb +2 -2
- data/spec/performance/comparator_spec.rb +136 -6
- data/spec/performance/event_baseline_spec.rb +156 -28
- data/spec/performance/release_lineage_spec.rb +53 -14
- data/spec/performance/runner_spec.rb +134 -0
- metadata +9 -3
data/doc/performance.md
CHANGED
|
@@ -15,39 +15,45 @@ every future performance characteristic.
|
|
|
15
15
|
|
|
16
16
|
## Local commands
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
-
|
|
41
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
environment
|
|
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.
|
|
63
|
-
|
|
64
|
-
|
|
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,
|
|
68
|
-
remains explicitly inconclusive; a successful process exit
|
|
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
|
|
144
|
-
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
|
187
|
-
|
|
|
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,
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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.
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
the
|
|
226
|
-
local Ruby component VERSION assignments
|
|
227
|
-
`haveapi-client` requirements. All remaining
|
|
228
|
-
|
|
229
|
-
runtime graph and
|
|
230
|
-
|
|
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
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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'
|
data/lib/haveapi/version.rb
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
<li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/js" target="_blank" rel="noopener">JavaScript and TypeScript (callback and async)</a></li>
|
|
21
21
|
<li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/python" target="_blank" rel="noopener">Python (sync and async)</a></li>
|
|
22
22
|
<% settings.api_server.documentation_client_profiles(@v).each do |language, profile| %>
|
|
23
|
-
<li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/<%= escape_html(language) %>" target="_blank" rel="noopener"><%=
|
|
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
|
|
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
|