haveapi 0.30.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/Gemfile +1 -0
  4. data/benchmarks/Gemfile +30 -0
  5. data/benchmarks/Gemfile.lock +296 -0
  6. data/benchmarks/documentation.rb +277 -0
  7. data/benchmarks/endpoints.rb +786 -0
  8. data/benchmarks/performance_budgets.json +1354 -0
  9. data/benchmarks/performance_compare.rb +678 -0
  10. data/benchmarks/shell.nix +21 -0
  11. data/doc/client-examples.md +179 -26
  12. data/doc/performance.md +310 -0
  13. data/haveapi.gemspec +1 -1
  14. data/lib/haveapi/client_examples/ts_generated_client.rb +36 -0
  15. data/lib/haveapi/documentation_clients.rb +49 -6
  16. data/lib/haveapi/documentation_context.rb +42 -12
  17. data/lib/haveapi/version.rb +1 -1
  18. data/lib/haveapi/views/version_page.erb +2 -2
  19. data/spec/client_examples/context_performance_spec.rb +72 -0
  20. data/spec/client_examples/context_spec.rb +206 -0
  21. data/spec/client_examples/profile_mount_spec.rb +45 -0
  22. data/spec/client_examples/ts_generated_client_spec.rb +53 -0
  23. data/spec/client_examples/typescript_profiles_spec.rb +113 -0
  24. data/spec/documentation/client_examples_spec.rb +2 -2
  25. data/spec/documentation/performance_spec.rb +79 -0
  26. data/spec/performance/comparator_spec.rb +472 -0
  27. data/spec/performance/endpoint_contract_spec.rb +226 -0
  28. data/spec/performance/endpoint_scaling_spec.rb +82 -0
  29. data/spec/performance/event_baseline_spec.rb +407 -0
  30. data/spec/performance/json_activation_spec.rb +151 -0
  31. data/spec/performance/release_lineage_spec.rb +470 -0
  32. data/spec/performance/runner_spec.rb +610 -0
  33. data/spec/performance/sqlite_spec.rb +194 -0
  34. data/spec/performance/support/live_worker.rb +44 -0
  35. data/spec/performance/support/samples.rb +72 -0
  36. data/test_support/documentation_performance_fixture.rb +106 -0
  37. data/test_support/performance_endpoint_fixture.rb +350 -0
  38. data/test_support/performance_sqlite_fixture.rb +384 -0
  39. metadata +29 -3
@@ -0,0 +1,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 Go and Rust SDKs in overview, initialization, authentication and actions' do
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
@@ -0,0 +1,79 @@
1
+ require 'spec_helper'
2
+ require 'base64'
3
+ require_relative '../../test_support/documentation_performance_fixture'
4
+
5
+ describe HaveAPI::Server, '#mount' do
6
+ let(:server) do
7
+ HaveAPI::DocumentationPerformanceFixture.build(resource_count: 3, actions_per_resource: 2)
8
+ end
9
+ let(:contexts) { [] }
10
+ let(:help_inputs) { [] }
11
+
12
+ before do
13
+ HaveAPI::ClientExample.clients.each do |client|
14
+ allow(client).to receive(:render).and_wrap_original do |original, kind, context:, **arguments|
15
+ contexts << context
16
+ original.call(kind, context:, **arguments)
17
+ end
18
+ end
19
+ allow(server).to receive(:describe_version).and_wrap_original do |original, *arguments|
20
+ original.call(*arguments).tap do |help|
21
+ help_inputs << [help, Marshal.dump(help)]
22
+ end
23
+ end
24
+ end
25
+
26
+ def request_as(user)
27
+ contexts.clear
28
+ Rack::MockRequest.new(server.app).get(
29
+ '/benchmark/api/v1/', 'HTTP_HOST' => 'example.test',
30
+ 'HTTP_AUTHORIZATION' => "Basic #{Base64.strict_encode64("#{user}:fixture-only")}"
31
+ )
32
+ end
33
+
34
+ def expect_shared_request(response)
35
+ expect(response.status).to eq(200)
36
+ expect(response.body).to include('PUBLIC_SAMPLE_0', 'example-resource0-action0-0',
37
+ 'init-javascript-callback', 'init-javascript-async',
38
+ 'init-typescript-callback', 'init-typescript-async',
39
+ 'init-python-sync', 'init-python-async')
40
+ expect(contexts.length).to be > 20
41
+ description = contexts.first.description
42
+ contexts.each { |context| expect(context.description).to equal(description) }
43
+ expect(description).to be_frozen
44
+ description
45
+ end
46
+
47
+ it 'shares one description across real panels and isolates sequential admin/member/admin requests' do
48
+ first_admin = request_as('admin')
49
+ admin_description = expect_shared_request(first_admin)
50
+ privileged_markers = %w[ADMIN_ONLY_ACTION ADMIN_ONLY_DEFAULT ADMIN_ONLY_REQUEST ADMIN_ONLY_RESPONSE
51
+ ADMIN_ONLY_ACTION_SAMPLE admin_field admin_only]
52
+ privileged_markers.each { |marker| expect(first_admin.body).to include(marker) }
53
+
54
+ member = request_as('member')
55
+ member_description = expect_shared_request(member)
56
+ expect(member_description).not_to equal(admin_description)
57
+ privileged_markers.each { |marker| expect(member.body).not_to include(marker) }
58
+ expect(JSON.generate(contexts.map { |context| [context.description, context.action, context.sample] }))
59
+ .not_to match(/ADMIN_ONLY_|admin_field|admin_only/)
60
+ expect(member_description[:resources]['resource0'][:actions]).not_to have_key('admin_only')
61
+ selected = contexts.find { |context| context.action_name == 'action0' }
62
+ expect(selected.action[:input][:parameters]).not_to have_key(:admin_field)
63
+ expect(selected.sample[:request]).to include(enabled: false, count: 0, nullable: nil, value0: '')
64
+
65
+ last_admin = request_as('admin')
66
+ restored_description = expect_shared_request(last_admin)
67
+ expect(restored_description).not_to equal(admin_description)
68
+ expect(restored_description).not_to equal(member_description)
69
+ expect(restored_description).to eq(admin_description)
70
+ privileged_markers.each { |marker| expect(last_admin.body).to include(marker) }
71
+
72
+ help_inputs.each do |help, original|
73
+ expect(Marshal.dump(help)).to eq(original)
74
+ expect(help).not_to be_frozen
75
+ expect(help[:resources]).not_to be_frozen
76
+ expect(help[:resources]['resource0'][:actions]['action0'][:examples].first[:request]).not_to be_frozen
77
+ end
78
+ end
79
+ end