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