haveapi 0.30.2 → 0.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/benchmarks/Gemfile +30 -0
- data/benchmarks/Gemfile.lock +296 -0
- data/benchmarks/endpoints.rb +77 -21
- data/benchmarks/performance_compare.rb +204 -19
- data/benchmarks/shell.nix +21 -0
- data/benchmarks/spec/client_examples_spec.rb +216 -0
- data/doc/client-examples.md +88 -35
- data/doc/performance.md +163 -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 +185 -30
- data/spec/performance/release_lineage_spec.rb +53 -14
- data/spec/performance/runner_spec.rb +136 -0
- data/spec/performance/source_versions_spec.rb +168 -0
- data/test_support/all_client_performance_fixture.rb +84 -0
- metadata +12 -3
|
@@ -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
|
|
@@ -42,10 +42,10 @@ describe HaveAPI::ClientExample do
|
|
|
42
42
|
expect(last_response.body).to include('Initialization', '<h4>Ruby</h4>', '<h4>PHP</h4>', '<h4>curl</h4>')
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
it 'hides unconfigured
|
|
45
|
+
it 'hides unconfigured generated SDKs in overview, initialization, authentication and actions' do
|
|
46
46
|
get '/v1/'
|
|
47
47
|
expect(last_response.status).to eq(200)
|
|
48
|
-
expect(last_response.body).not_to include('clients/go', 'clients/rust', 'init-go', 'init-rust', '>Go</h', '>Rust (')
|
|
48
|
+
expect(last_response.body).not_to include('clients/go', 'clients/rust', 'init-go', 'init-rust', 'typescript-generated', '>Go</h', '>Rust (')
|
|
49
49
|
expect(last_response.body).to include('init-javascript-callback', 'init-typescript-async', 'init-python-sync')
|
|
50
50
|
expect(last_response.body).to include('example-widget-show-0-javascript-callback')
|
|
51
51
|
end
|
|
@@ -4,12 +4,12 @@ require 'open3'
|
|
|
4
4
|
require_relative 'support/samples'
|
|
5
5
|
|
|
6
6
|
describe HaveAPI::Performance::Comparator do
|
|
7
|
-
def compare_cli(samples, budgets)
|
|
7
|
+
def compare_cli(samples, budgets, sample_bytes: JSON.generate(samples))
|
|
8
8
|
Dir.mktmpdir('haveapi-comparator-') do |directory|
|
|
9
9
|
input = File.join(directory, 'samples.json')
|
|
10
10
|
policy = File.join(directory, 'budgets.json')
|
|
11
11
|
output = File.join(directory, 'result')
|
|
12
|
-
File.
|
|
12
|
+
File.binwrite(input, sample_bytes)
|
|
13
13
|
File.write(policy, budgets)
|
|
14
14
|
_stdout, _stderr, status = Open3.capture3(RbConfig.ruby, File.expand_path('../../benchmarks/endpoints.rb', __dir__),
|
|
15
15
|
'compare', '--input', input, '--budgets', policy, '--output', output)
|
|
@@ -25,6 +25,14 @@ describe HaveAPI::Performance::Comparator do
|
|
|
25
25
|
output.merge!('body_sha256' => digest, 'body_bytes' => body.bytesize)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
def replace_response(sample, body, json: true)
|
|
29
|
+
replace_raw_body(sample, body)
|
|
30
|
+
canonical = json ? JSON.parse(body) : nil
|
|
31
|
+
sample['bodies'].values.first['canonical_body'] = canonical
|
|
32
|
+
sample['outputs'].first.merge!('envelope_status' => json ? canonical.fetch('status') : nil,
|
|
33
|
+
'canonical_sha256' => json ? HaveAPI::Performance::Identity.digest(canonical) : Digest::SHA256.hexdigest(body))
|
|
34
|
+
end
|
|
35
|
+
|
|
28
36
|
def rebind_source(samples, role, sha)
|
|
29
37
|
samples["#{role}_sha"] = sha
|
|
30
38
|
samples['sources'][role]['sha'] = sha
|
|
@@ -44,6 +52,99 @@ describe HaveAPI::Performance::Comparator do
|
|
|
44
52
|
expect(JSON.generate(report)).not_to include('canonical_body', 'body_sha256', 'self_pairs', 'observations', 'loaded_sources')
|
|
45
53
|
end
|
|
46
54
|
|
|
55
|
+
it 'attributes replay success and loaded-input errors to exact input/comparator bytes and the original measurement harness' do
|
|
56
|
+
samples, budgets = PerformanceSamples.fixture
|
|
57
|
+
[false, true].each do |broken|
|
|
58
|
+
samples['schema_version'] = broken ? 1 : 2
|
|
59
|
+
bytes = "#{JSON.pretty_generate(samples)}\n"
|
|
60
|
+
code, report = compare_cli(samples, budgets, sample_bytes: bytes)
|
|
61
|
+
expect(code).to eq(broken ? 3 : 0)
|
|
62
|
+
expect(report['harness_sha256']).to eq('e' * 64)
|
|
63
|
+
expect(report.values_at('baseline_sha', 'candidate_sha', 'budget_sha256')).to eq(samples.values_at('baseline_sha', 'candidate_sha', 'budget_sha256'))
|
|
64
|
+
expect(report['comparison']).to eq('policy' => 'per-revision-response-stability-v1',
|
|
65
|
+
'comparator_sha256' => Digest::SHA256.file(File.expand_path('../../benchmarks/performance_compare.rb', __dir__)).hexdigest,
|
|
66
|
+
'samples_sha256' => Digest::SHA256.hexdigest(bytes))
|
|
67
|
+
end
|
|
68
|
+
code, report = compare_cli(samples, budgets, sample_bytes: '{')
|
|
69
|
+
expect(code).to eq(3)
|
|
70
|
+
expect(report).not_to have_key('harness_sha256')
|
|
71
|
+
expect(report.dig('comparison', 'samples_sha256')).to eq(Digest::SHA256.hexdigest('{'))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'classifies stable changed JSON and HTML normally without changing performance budgets' do
|
|
75
|
+
samples, budgets = PerformanceSamples.fixture
|
|
76
|
+
samples['cases'].each do |item|
|
|
77
|
+
html = item['id'].start_with?('docs.')
|
|
78
|
+
(item['self_pairs'] + item['pairs']).each do |pair|
|
|
79
|
+
%w[baseline candidate].each do |role|
|
|
80
|
+
actual = samples['observations'][pair[role]['observation']]['source']['sha']
|
|
81
|
+
value = actual == samples['baseline_sha'] ? 'before' : 'after'
|
|
82
|
+
body = html ? "<!doctype html><html><body>#{value}</body></html>" : JSON.generate(status: true, response: value)
|
|
83
|
+
replace_response(pair[role], body, json: !html)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
code, report = compare_cli(samples, budgets)
|
|
88
|
+
expect(code).to eq(0)
|
|
89
|
+
expect(report['status']).to eq('pass')
|
|
90
|
+
expect(report['cases'].map { |item| item['responses'].slice('raw_changed', 'canonical_changed') }).to all(eq('raw_changed' => true, 'canonical_changed' => true))
|
|
91
|
+
%w[wall_seconds cpu_seconds allocated_objects].each do |metric|
|
|
92
|
+
changed = Marshal.load(Marshal.dump(samples))
|
|
93
|
+
changed['cases'].first['pairs'].each { |pair| pair['candidate'][metric] = metric == 'allocated_objects' ? 140 : 3.0 }
|
|
94
|
+
regression_code, regression_report = compare_cli(changed, budgets)
|
|
95
|
+
expect(regression_code).to eq(1), metric
|
|
96
|
+
expect(regression_report['status']).to eq('regression')
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'rejects canonical variation within a source including the second baseline/self column' do
|
|
101
|
+
%w[self_pairs pairs].each do |group|
|
|
102
|
+
samples, budgets = PerformanceSamples.fixture
|
|
103
|
+
replace_response(samples['cases'].first[group].last['candidate'], '{"status":true,"response":"changed"}')
|
|
104
|
+
code, report = compare_cli(samples, budgets)
|
|
105
|
+
expect(code).to eq(3)
|
|
106
|
+
expected_sha = samples[group == 'self_pairs' ? 'baseline_sha' : 'candidate_sha']
|
|
107
|
+
expect(report.dig('error', 'message')).to eq("Response identity differs within revision #{expected_sha}")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'replays benchmark inputs with different unused contributor flakes and rejects tampered consumed or source inputs' do
|
|
112
|
+
%w[quick weekly].each do |profile|
|
|
113
|
+
samples, budgets = PerformanceSamples.fixture(profile:)
|
|
114
|
+
tool = HaveAPI::Performance::BENCHMARK_INPUTS.to_h do |path|
|
|
115
|
+
content = "controlled #{path}\n"
|
|
116
|
+
[path, { 'content' => content, 'sha256' => Digest::SHA256.hexdigest(content) }]
|
|
117
|
+
end
|
|
118
|
+
samples.fetch('observations').each_value do |observation|
|
|
119
|
+
role = observation.fetch('source').fetch('sha') == samples['baseline_sha'] ? 'baseline' : 'candidate'
|
|
120
|
+
inputs = Marshal.load(Marshal.dump(observation.fetch('inputs')))
|
|
121
|
+
inputs['benchmark'] = tool
|
|
122
|
+
inputs['locks'] = { 'servers/ruby/benchmarks/Gemfile.lock' => tool.fetch('servers/ruby/benchmarks/Gemfile.lock').fetch('sha256') }
|
|
123
|
+
inputs['contributor_flakes'] = %w[flake.nix flake.lock].to_h { |path| [path, { 'content' => "#{role} #{path}" }] }
|
|
124
|
+
inputs['contributor_flakes'].each do |path, input|
|
|
125
|
+
samples['sources'][role]['files_sha256'][path] = Digest::SHA256.hexdigest(input.fetch('content'))
|
|
126
|
+
end
|
|
127
|
+
inputs['manifests']['.ruby-version'] = Digest::SHA256.hexdigest('3.2.0')
|
|
128
|
+
samples['sources'][role]['files_sha256']['.ruby-version'] = inputs['manifests']['.ruby-version']
|
|
129
|
+
observation['inputs'] = inputs
|
|
130
|
+
end
|
|
131
|
+
samples['inputs'] = HaveAPI::Performance::Release.graph(samples['observations']['baseline']['inputs'])
|
|
132
|
+
expect(compare_cli(samples, budgets).first).to eq(0)
|
|
133
|
+
%w[tool lock raw-flake declaration ruby].each do |fault|
|
|
134
|
+
changed = Marshal.load(Marshal.dump(samples))
|
|
135
|
+
inputs = changed['observations']['candidate']['inputs']
|
|
136
|
+
case fault
|
|
137
|
+
when 'tool' then inputs['benchmark']['servers/ruby/benchmarks/shell.nix']['content'] << 'tampered'
|
|
138
|
+
when 'lock' then inputs['locks']['servers/ruby/benchmarks/Gemfile.lock'] = 'f' * 64
|
|
139
|
+
when 'raw-flake' then inputs['contributor_flakes']['flake.nix']['content'] << 'tampered'
|
|
140
|
+
when 'declaration' then inputs['manifests']['servers/ruby/Gemfile'] = 'f' * 64
|
|
141
|
+
else inputs['manifests']['.ruby-version'] = 'f' * 64
|
|
142
|
+
end
|
|
143
|
+
expect(compare_cli(changed, budgets).first).to eq(3), "#{profile}:#{fault}"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
47
148
|
it 'rejects unsupported sample formats without converting historical evidence' do
|
|
48
149
|
samples, budgets = PerformanceSamples.fixture
|
|
49
150
|
samples['schema_version'] = 1
|
|
@@ -170,7 +271,7 @@ describe HaveAPI::Performance::Comparator do
|
|
|
170
271
|
end
|
|
171
272
|
end
|
|
172
273
|
|
|
173
|
-
it 'compares response occurrence totals
|
|
274
|
+
it 'compares response occurrence totals within each revision without retaining request order' do
|
|
174
275
|
samples, json = PerformanceSamples.fixture
|
|
175
276
|
spec = samples['cases'].first
|
|
176
277
|
spec['iterations'] = 3
|
|
@@ -178,7 +279,7 @@ describe HaveAPI::Performance::Comparator do
|
|
|
178
279
|
%w[baseline candidate].each do |role|
|
|
179
280
|
sample = pair[role]
|
|
180
281
|
output = sample['outputs'].first
|
|
181
|
-
body = sample['bodies'].values.first['body'].sub('"zero":0', '"zero":
|
|
282
|
+
body = sample['bodies'].values.first['body'].sub('"zero":0', '"zero":100')
|
|
182
283
|
canonical = JSON.parse(body)
|
|
183
284
|
digest = Digest::SHA256.hexdigest(body)
|
|
184
285
|
sample['bodies'][digest] = { 'body' => body, 'canonical_body' => canonical }
|
|
@@ -189,9 +290,16 @@ describe HaveAPI::Performance::Comparator do
|
|
|
189
290
|
end
|
|
190
291
|
end
|
|
191
292
|
comparator = described_class.new(samples, JSON.parse(json), budget_sha256: samples['budget_sha256'])
|
|
192
|
-
|
|
293
|
+
report = comparator.compare
|
|
294
|
+
expect(report['status']).to eq('pass')
|
|
295
|
+
original_bytes = spec['pairs'].first['baseline']['outputs'].first['body_bytes']
|
|
296
|
+
expect(report['cases'].first.dig('responses', 'bytes_per_request', 'baseline')).to be_within(0.00001).of(original_bytes + (4.0 / 3))
|
|
193
297
|
spec['pairs'].last['candidate']['outputs'].reverse!
|
|
194
298
|
expect(comparator.compare['status']).to eq('pass')
|
|
299
|
+
spec['pairs'].each { |pair| pair['candidate']['outputs'].each { |example| example['count'] = 3 - example['count'] } }
|
|
300
|
+
report = comparator.compare
|
|
301
|
+
expect(report['status']).to eq('pass')
|
|
302
|
+
expect(report['cases'].first.dig('responses', 'canonical_changed')).to be(true)
|
|
195
303
|
spec['pairs'].last['candidate']['outputs'].each { |example| example['count'] = 3 - example['count'] }
|
|
196
304
|
expect { comparator.compare }.to raise_error(HaveAPI::Performance::ContractError, /Response identity/)
|
|
197
305
|
end
|
|
@@ -226,7 +334,29 @@ describe HaveAPI::Performance::Comparator do
|
|
|
226
334
|
body = JSON.parse(target['bodies'].values.first['body'])
|
|
227
335
|
body['response'] = body['response'].to_a.reverse.to_h
|
|
228
336
|
replace_raw_body(target, JSON.generate(body.to_a.reverse.to_h))
|
|
229
|
-
|
|
337
|
+
code, report = compare_cli(samples, budgets)
|
|
338
|
+
expect(code).to eq(0)
|
|
339
|
+
expect(report['cases'].first['responses']).to include('raw_changed' => true, 'canonical_changed' => false)
|
|
340
|
+
expect(report['cases'].first.dig('responses', 'bytes_per_request', 'delta')).to eq(0)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
it 'reports weighted byte deltas from baseline/candidate pairs without self-pair bias or a raw determinism requirement' do
|
|
344
|
+
samples, budgets = PerformanceSamples.fixture
|
|
345
|
+
item = samples['cases'].first
|
|
346
|
+
baseline_bytes = item['pairs'].first['baseline']['outputs'].first['body_bytes']
|
|
347
|
+
item['self_pairs'].each do |pair|
|
|
348
|
+
%w[baseline candidate].each { |role| replace_raw_body(pair[role], " #{pair[role]['bodies'].values.first['body']}") }
|
|
349
|
+
end
|
|
350
|
+
item['pairs'].last(2).each do |pair|
|
|
351
|
+
replace_raw_body(pair['candidate'], " #{pair['candidate']['bodies'].values.first['body']}")
|
|
352
|
+
end
|
|
353
|
+
code, report = compare_cli(samples, budgets)
|
|
354
|
+
expect(code).to eq(0)
|
|
355
|
+
response = report['cases'].first['responses']
|
|
356
|
+
expect(response).to include('raw_changed' => true, 'canonical_changed' => false)
|
|
357
|
+
expect(response['bytes_per_request']).to include('baseline' => baseline_bytes, 'candidate' => baseline_bytes + 0.8)
|
|
358
|
+
expect(response['bytes_per_request']['delta']).to be_within(0.00001).of(0.8)
|
|
359
|
+
expect(response['bytes_per_request']['delta_percent']).to be_within(0.00001).of(80.0 / baseline_bytes)
|
|
230
360
|
end
|
|
231
361
|
|
|
232
362
|
it 'rejects identical source SHAs even with consistently rebound provenance' do
|