haveapi-go-client 0.29.12 → 0.30.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/.gitignore +1 -0
- data/README.md +5 -0
- data/docs/documentation.md +190 -0
- data/haveapi-go-client.gemspec +1 -1
- data/lib/haveapi/go_client/documentation.rb +523 -0
- data/lib/haveapi/go_client/generator.rb +16 -3
- data/lib/haveapi/go_client/version.rb +1 -1
- data/spec/documentation.Gemfile +6 -0
- data/spec/documentation_acceptance.rb +273 -0
- data/spec/documentation_build_check.rb +32 -0
- data/spec/documentation_profile_check.rb +99 -0
- data/spec/documentation_wire_check.rb +94 -0
- data/spec/support/documentation_fixture.rb +70 -0
- data/spec/support/documentation_profile_fixture.rb +122 -0
- data/spec/support/test_server.rb +1 -1
- data/spec/unit/documentation_spec.rb +356 -0
- metadata +13 -3
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Real mounted DSL for the pure documentation-profile check; no action is run.
|
|
2
|
+
module GoDocumentationProfileFixture
|
|
3
|
+
HOSTILE = "quote\"\\\n雪</script>".freeze
|
|
4
|
+
|
|
5
|
+
module Resources
|
|
6
|
+
class Owner < HaveAPI::Resource
|
|
7
|
+
version '7'
|
|
8
|
+
auth false
|
|
9
|
+
|
|
10
|
+
define_action(:Index) do
|
|
11
|
+
authorize { allow }
|
|
12
|
+
output(:hash_list) do
|
|
13
|
+
integer :id
|
|
14
|
+
string :label
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
define_action(:Show) do
|
|
19
|
+
route '{owner_id}'
|
|
20
|
+
authorize { allow }
|
|
21
|
+
output(:hash) do
|
|
22
|
+
integer :id
|
|
23
|
+
string :label
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class SetTimeout < HaveAPI::Resource
|
|
29
|
+
version '7'
|
|
30
|
+
auth false
|
|
31
|
+
|
|
32
|
+
define_action(:Secret) do
|
|
33
|
+
authorize { |user| user == :privileged ? allow : deny }
|
|
34
|
+
output(:hash) { string :private_value }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class SetTimeoutResource < HaveAPI::Resource
|
|
39
|
+
version '7'
|
|
40
|
+
route 'parents'
|
|
41
|
+
auth false
|
|
42
|
+
|
|
43
|
+
define_resource(:Child) do
|
|
44
|
+
route '{parent_id}/children'
|
|
45
|
+
auth false
|
|
46
|
+
define_action(:Client) do
|
|
47
|
+
route '{child_id}'
|
|
48
|
+
http_method :post
|
|
49
|
+
aliases ['perform']
|
|
50
|
+
authorize do |user|
|
|
51
|
+
input blacklist: [:private_value] unless user == :privileged
|
|
52
|
+
output blacklist: [:private_value] unless user == :privileged
|
|
53
|
+
allow
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
input(:hash) do
|
|
57
|
+
string :name, nullable: true
|
|
58
|
+
bool :enabled
|
|
59
|
+
integer :count
|
|
60
|
+
string :private_value, default: 'private default'
|
|
61
|
+
string :unused
|
|
62
|
+
end
|
|
63
|
+
output(:hash) do
|
|
64
|
+
integer :id
|
|
65
|
+
string :private_value
|
|
66
|
+
resource Owner, name: :owner, nullable: true
|
|
67
|
+
end
|
|
68
|
+
meta(:global) do
|
|
69
|
+
input { custom :includes }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
example 'filtered sample' do # rubocop:disable RSpec/NoExpectationExample -- HaveAPI sample DSL.
|
|
73
|
+
path_params 'parent/with space', 0
|
|
74
|
+
request name: HOSTILE, enabled: false, count: 0, private_value: 'private sample'
|
|
75
|
+
response id: 0, private_value: 'private result', owner: { id: 9 }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def exec
|
|
79
|
+
raise 'Documentation must not execute an action'
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
define_action(:Update) do
|
|
85
|
+
self.action_name = 'child'
|
|
86
|
+
route 'update'
|
|
87
|
+
aliases ['perform']
|
|
88
|
+
authorize { allow }
|
|
89
|
+
input(:hash) { string :name, nullable: true }
|
|
90
|
+
output(:hash) { integer :id }
|
|
91
|
+
|
|
92
|
+
example 'nullable sample' do # rubocop:disable RSpec/NoExpectationExample -- HaveAPI sample DSL.
|
|
93
|
+
request name: nil
|
|
94
|
+
response id: 0
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.server(**go)
|
|
101
|
+
server = HaveAPI::Server.new(Resources)
|
|
102
|
+
server.use_version('7')
|
|
103
|
+
server.default_version = '7'
|
|
104
|
+
server.documentation_clients('7', go: { import_path: 'example.test/current-sdk/client', **go })
|
|
105
|
+
server.mount('/mounted/')
|
|
106
|
+
server
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def self.description(server, user: nil)
|
|
110
|
+
server.describe_version(HaveAPI::Context.new(server, version: '7', user:, endpoint: false, doc: true))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.context(description, path: %w[set_timeout_resource child], action_name: 'client', sample: nil)
|
|
114
|
+
resource = path.inject(description) { |parent, name| parent.fetch(:resources).fetch(name) }
|
|
115
|
+
action = resource.fetch(:actions).fetch(action_name)
|
|
116
|
+
HaveAPI::ClientExample::Context.new(
|
|
117
|
+
version: '7', host: 'api.example.test', base_url: 'https://api.example.test/mounted/',
|
|
118
|
+
version_url: 'https://api.example.test/mounted/v7/', description:, resource_path: path,
|
|
119
|
+
resource:, action_name:, action:, sample: sample || action.fetch(:examples).first
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
end
|
data/spec/support/test_server.rb
CHANGED
|
@@ -20,7 +20,7 @@ class ClientTestServer
|
|
|
20
20
|
def start
|
|
21
21
|
return if @wait_thr
|
|
22
22
|
|
|
23
|
-
env = { 'BUNDLE_GEMFILE' => @gemfile }
|
|
23
|
+
env = { 'BUNDLE_GEMFILE' => @gemfile, 'BUNDLE_LOCKFILE' => "#{@gemfile}.lock" }
|
|
24
24
|
cmd = ['bundle', 'exec', 'ruby', @server_script, '--port', '0']
|
|
25
25
|
@stdin, @stdout, @wait_thr = Open3.popen2e(env, *cmd, chdir: @cwd)
|
|
26
26
|
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
require 'haveapi/go_client/documentation'
|
|
2
|
+
require_relative '../support/documentation_fixture'
|
|
3
|
+
|
|
4
|
+
RSpec.describe HaveAPI::GoClient::Documentation do
|
|
5
|
+
let(:description) { GoDocumentationFixture.description }
|
|
6
|
+
let(:adapter) { described_class.new(description:, import_path: 'example.test/sdk/client', module_version: 'v1.2.3') }
|
|
7
|
+
let(:action) { description.dig(:resources, :client, :resources, :child, :actions, :client) }
|
|
8
|
+
let(:sample) { GoDocumentationFixture.sample }
|
|
9
|
+
let(:context) do
|
|
10
|
+
Struct.new(:version, :base_url, :description, :resource_path, :action_name, :action, :sample).new(version: '7', base_url: 'https://api.example.test/mounted/', description:,
|
|
11
|
+
resource_path: %w[client child], action_name: 'client', action:, sample:)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def render
|
|
15
|
+
adapter.render(kind: :example, context:, mode: :sync)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'leaves an omitted module version unresolved and prints the Go latest selector' do
|
|
19
|
+
unversioned = described_class.new(description:, import_path: 'example.test/sdk/client')
|
|
20
|
+
expect(unversioned).to be_frozen
|
|
21
|
+
expect(unversioned.instance_variable_get(:@module_version)).to be_nil
|
|
22
|
+
expect(unversioned.render(kind: :init, context:, mode: :sync).fetch(:code)).to start_with("// go get example.test/sdk/client@latest\n")
|
|
23
|
+
expect(unversioned.render(kind: :example, context:, mode: :sync).fetch(:code)).to eq(
|
|
24
|
+
render.fetch(:code).sub('@v1.2.3', '@latest')
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'treats an explicit nil version as omission without changing the canonical model' do
|
|
29
|
+
unversioned = described_class.new(description:, import_path: 'example.test/sdk/client', module_version: nil)
|
|
30
|
+
resolved = unversioned.resolve(resource_path: %w[client child], action_name: 'perform')
|
|
31
|
+
expect(resolved.action.go_member_names).to eq(adapter.resolve(resource_path: %w[client child], action_name: 'perform').action.go_member_names)
|
|
32
|
+
expect(unversioned.render(kind: :example, context:, mode: :sync).fetch(:code)).to include('client "example.test/sdk/client"', 'api.Client.Child.ClientAction.Prepare()')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'still rejects hostile imports when the module version is omitted' do
|
|
36
|
+
expect do
|
|
37
|
+
described_class.new(description:, import_path: "example.test/sdk/client\npackage injected")
|
|
38
|
+
end.to raise_error(ArgumentError, 'invalid Go SDK import path or module version')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'retains explicit version selectors and copies configuration strings' do
|
|
42
|
+
version = +'v2.3.4-rc.1+build.7'
|
|
43
|
+
path = +'example.test/sdk/v2/client'
|
|
44
|
+
pinned = described_class.new(description:, import_path: path, module_version: version)
|
|
45
|
+
version.replace('v9.9.9')
|
|
46
|
+
path.replace('other.test/sdk/client')
|
|
47
|
+
expect(pinned.render(kind: :init, context:, mode: :sync).fetch(:code)).to start_with("// go get example.test/sdk/v2/client@v2.3.4-rc.1+build.7\n")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
['', 'latest', '1.2.3', "v1.2.3\npackage injected", false, 7].each do |invalid|
|
|
51
|
+
it "rejects a supplied invalid module version #{invalid.inspect}" do
|
|
52
|
+
expect do
|
|
53
|
+
described_class.new(description:, import_path: 'example.test/sdk/client', module_version: invalid)
|
|
54
|
+
end.to raise_error(ArgumentError, 'invalid Go SDK import path or module version')
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'resolves canonical generator members and aliases without generator or serving I/O' do
|
|
59
|
+
allow(HaveAPI::GoClient::Generator).to receive(:new).and_call_original
|
|
60
|
+
allow(HaveAPI::GoClient::Generator).to receive(:allocate).and_call_original
|
|
61
|
+
allow(File).to receive(:read).and_call_original
|
|
62
|
+
model = HaveAPI::GoClient::ApiVersion.new(GoDocumentationFixture.description)
|
|
63
|
+
resolved = adapter.resolve(resource_path: %w[client child], action_name: 'perform')
|
|
64
|
+
canonical = model.resources.find { |resource| resource.name == 'client' }.resources.first.actions.first
|
|
65
|
+
|
|
66
|
+
expect(resolved.action.go_member_names).to eq(canonical.go_member_names)
|
|
67
|
+
expect(resolved.resource.resource_path.map(&:go_member_name)).to eq(%w[Client Child])
|
|
68
|
+
expect(resolved.action.go_member_name).to eq('ClientAction')
|
|
69
|
+
expect(render[:status]).to eq(:code)
|
|
70
|
+
expect(HaveAPI::GoClient::Generator).not_to have_received(:new)
|
|
71
|
+
expect(HaveAPI::GoClient::Generator).not_to have_received(:allocate)
|
|
72
|
+
expect(File).not_to have_received(:read)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'uses the full saved model even when the visible description has fewer siblings' do
|
|
76
|
+
description[:resources][:set_timeout] = { resources: {}, actions: {} }
|
|
77
|
+
adapter
|
|
78
|
+
context.description = Marshal.load(Marshal.dump(description))
|
|
79
|
+
context.description[:resources].delete(:hidden)
|
|
80
|
+
context.description[:resources].delete(:set_timeout)
|
|
81
|
+
|
|
82
|
+
expect(adapter.resolve(resource_path: ['set_timeout'], action_name: 'missing')).to be_nil
|
|
83
|
+
expect(render[:code]).not_to include('hidden', 'secret', 'SetTimeout')
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it 'selects no inputs before supplied setters, preserving null, omission, false and zero' do
|
|
87
|
+
result = render
|
|
88
|
+
expect(result).to be_frozen
|
|
89
|
+
expect(result[:code]).to include('client "example.test/sdk/client"', 'client.New("https://api.example.test")',
|
|
90
|
+
'api.Client.Child.ClientAction.Prepare()',
|
|
91
|
+
'invocation.SetPathParamInt("client_id", 42)',
|
|
92
|
+
'invocation.SetPathParamString("child_id", "a/b")',
|
|
93
|
+
'input := invocation.NewInput().SelectParameters()', 'input.SetNameNil(true)',
|
|
94
|
+
'input.SetEnabled(false)', 'input.SetCount(0)')
|
|
95
|
+
expect(result[:code]).not_to include('SetUnused')
|
|
96
|
+
expect(result[:code].index('SelectParameters()')).to be < result[:code].index('input.SetNameNil')
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'preserves an explicitly empty request and escapes source literals' do
|
|
100
|
+
sample[:request] = {}
|
|
101
|
+
expect(render[:code]).to include('invocation.NewInput().SelectParameters()')
|
|
102
|
+
expect(render[:code]).not_to include('input :=')
|
|
103
|
+
sample[:request] = { name: "quote\"\\\n雪</script>" }
|
|
104
|
+
expect(render[:code]).to include('input.SetName("quote\\"\\\\\\n雪</script>")')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'rejects list input, Custom fields, invalid values, missing paths and stale contracts' do
|
|
108
|
+
sample[:request][:count] = 'wrong'
|
|
109
|
+
expect(render[:status]).to eq(:limitation)
|
|
110
|
+
sample[:request][:count] = 0
|
|
111
|
+
sample[:path_params].pop
|
|
112
|
+
expect(render[:reason]).to include('path parameters')
|
|
113
|
+
sample[:path_params] << 'child'
|
|
114
|
+
action[:input][:parameters][:name][:nullable] = false
|
|
115
|
+
expect(render[:status]).to eq(:limitation)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it 'reports a list-input limitation rather than discarding fields' do
|
|
119
|
+
action[:input][:layout] = 'hash_list'
|
|
120
|
+
sample[:request] = [{ name: 'a' }]
|
|
121
|
+
expect(render).to include(status: :limitation, reason: /list input/)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it 'reports unsupported Custom input and output without rendering its contents' do
|
|
125
|
+
action[:input][:parameters][:custom] = { type: 'Custom' }
|
|
126
|
+
sample[:request][:custom] = { secret_value: 'do not render' }
|
|
127
|
+
expect(render).to include(status: :limitation, reason: /Custom/)
|
|
128
|
+
expect(render.to_s).not_to include('secret_value', 'do not render')
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it 'supports list output and refuses scalar null output fidelity claims' do
|
|
132
|
+
action[:output][:layout] = 'hash_list'
|
|
133
|
+
sample[:response] = [{ id: 1 }, { id: 2 }]
|
|
134
|
+
expect(render[:code]).to include('for _, item := range response.Output', 'fmt.Println(item.Id)')
|
|
135
|
+
sample[:response] = [{ name: nil }]
|
|
136
|
+
expect(render).to include(status: :limitation, reason: /scalar null/)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it 'renders transport errors and API failures separately without invented success output' do
|
|
140
|
+
sample[:status] = false
|
|
141
|
+
sample[:response] = nil
|
|
142
|
+
expect(render[:code]).to include('if err != nil', 'if !response.Status', 'response.Message, response.Errors')
|
|
143
|
+
expect(render[:code]).not_to include('response.Output')
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it 'never exposes a saved action absent from the authorized description' do
|
|
147
|
+
adapter
|
|
148
|
+
context.description = Marshal.load(Marshal.dump(description))
|
|
149
|
+
context.description[:resources].delete(:client)
|
|
150
|
+
expect(render).to include(status: :limitation)
|
|
151
|
+
expect(render.to_s).not_to include('secret', 'hidden')
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it 'checks response schema drift only on fields in the filtered sample' do
|
|
155
|
+
adapter
|
|
156
|
+
context.description = Marshal.load(Marshal.dump(description))
|
|
157
|
+
context.description.dig(:resources, :client, :resources, :child, :actions, :client, :output, :parameters, :id)[:type] = 'String'
|
|
158
|
+
expect(render).to include(status: :limitation, reason: /response field/)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it 'only renders advertised authentication and uses environment placeholders' do
|
|
162
|
+
basic = adapter.render(kind: :auth, context:, mode: :sync, auth_method: :basic, auth_description: {})
|
|
163
|
+
expect(basic[:code]).to include('api.SetBasicAuthentication(os.Getenv("HAVEAPI_USER"), os.Getenv("HAVEAPI_PASSWORD"))')
|
|
164
|
+
oauth = adapter.render(kind: :auth, context:, mode: :sync, auth_method: :oauth2, auth_description: {})
|
|
165
|
+
expect(oauth[:code]).to include('api.SetExistingOAuth2Auth(os.Getenv("HAVEAPI_ACCESS_TOKEN"))')
|
|
166
|
+
token = adapter.render(kind: :auth, context:, mode: :sync, auth_method: :token, auth_description: {})
|
|
167
|
+
expect(token[:code]).to include('api.SetExistingTokenAuth(os.Getenv("HAVEAPI_TOKEN"))')
|
|
168
|
+
context.description = Marshal.load(Marshal.dump(description))
|
|
169
|
+
context.description[:authentication].delete(:token)
|
|
170
|
+
expect(adapter.render(kind: :auth, context:, mode: :sync, auth_method: :token, auth_description: {})).to include(status: :limitation)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it 'does not show operation helpers when action-state visibility is absent' do
|
|
174
|
+
action[:blocking] = true
|
|
175
|
+
action[:meta][:global][:output] = GoDocumentationFixture.io({ action_state_id: { type: 'Integer' } }, namespace: false)
|
|
176
|
+
adapter
|
|
177
|
+
context.description = Marshal.load(Marshal.dump(description))
|
|
178
|
+
context.description[:resources].delete(:action_state)
|
|
179
|
+
expect(render[:code]).not_to include('response.OperationStatus')
|
|
180
|
+
expect(render[:code]).to include('does not establish operation completion')
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it 'uses the origin with mounted versioned SDK paths and rejects a changed mount' do
|
|
184
|
+
expect(render[:code]).to include('client.New("https://api.example.test")')
|
|
185
|
+
expect(adapter.resolve(resource_path: %w[client child], action_name: 'client').action.path).to start_with('/mounted/v7/')
|
|
186
|
+
adapter
|
|
187
|
+
context.description = Marshal.load(Marshal.dump(description))
|
|
188
|
+
context.description.dig(:resources, :client, :resources, :child, :actions, :client)[:path] = '/other/v7/widgets'
|
|
189
|
+
expect(render).to include(status: :limitation, reason: /route/)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it 'preserves the URL constructor and its authentication and generation options' do
|
|
193
|
+
connection = instance_double(HaveAPI::Client::Communicator, describe_api: description, authenticate: nil)
|
|
194
|
+
allow(HaveAPI::Client::Communicator).to receive(:new).with('https://fixture.invalid').and_return(connection)
|
|
195
|
+
generator = HaveAPI::GoClient::Generator.new('https://fixture.invalid', '/unused',
|
|
196
|
+
version: '7', module: 'example.test/sdk', package: 'client',
|
|
197
|
+
basic_user: 'fixture-user', basic_password: 'fixture-password')
|
|
198
|
+
expect(connection).to have_received(:authenticate).with(:basic, user: 'fixture-user', password: 'fixture-password')
|
|
199
|
+
expect(connection).to have_received(:describe_api).with('7')
|
|
200
|
+
expect(generator.dst).to eq('/unused')
|
|
201
|
+
expect(generator.module).to eq('example.test/sdk')
|
|
202
|
+
expect(generator.package).to eq('client')
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
it 'builds a fixture generator from saved input without fetching or writing' do
|
|
206
|
+
allow(HaveAPI::Client::Communicator).to receive(:new).and_call_original
|
|
207
|
+
allow(FileUtils).to receive(:mkpath).and_call_original
|
|
208
|
+
generator = HaveAPI::GoClient::Generator.from_description(description, '/unused', module: 'example.test/sdk', package: 'client')
|
|
209
|
+
expect(generator.dst).to eq('/unused')
|
|
210
|
+
expect(HaveAPI::Client::Communicator).not_to have_received(:new)
|
|
211
|
+
expect(FileUtils).not_to have_received(:mkpath)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
it 'rejects malformed saved schemas at initialization' do
|
|
215
|
+
description[:resources][:client][:resources][:child][:actions][:client][:path] = nil
|
|
216
|
+
expect { adapter }.to raise_error(ArgumentError, /saved Go SDK action/)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it 'checks Go field and method name collisions' do
|
|
220
|
+
action[:input][:parameters][:select_parameters] = { type: 'String' }
|
|
221
|
+
expect(render).to include(status: :limitation, reason: /name collisions/)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
it 'shows operation methods only for visible matching state actions and metadata' do
|
|
225
|
+
action[:blocking] = true
|
|
226
|
+
action[:meta][:global][:output] = GoDocumentationFixture.io({ action_state_id: { type: 'Integer' } }, namespace: false)
|
|
227
|
+
expect(render[:code]).to include('response.IsBlocking()', 'response.OperationStatus()')
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
%i[global object].each do |role|
|
|
231
|
+
[true, false].each do |success|
|
|
232
|
+
it "limits #{role} metadata required in the saved SDK for status #{success}" do
|
|
233
|
+
action[:meta][role] = { input: GoDocumentationFixture.io({ hidden_requirement: { type: 'Boolean', required: true, default: 'private default' } }) }
|
|
234
|
+
sample[:status] = success
|
|
235
|
+
adapter
|
|
236
|
+
context.description = Marshal.load(Marshal.dump(description))
|
|
237
|
+
visible = context.description.dig(:resources, :client, :resources, :child, :actions, :client)
|
|
238
|
+
visible[:meta].delete(role)
|
|
239
|
+
|
|
240
|
+
expect(render).to include(status: :limitation, reason: 'This action requires metadata that is absent from the sample.')
|
|
241
|
+
expect(render.to_s).not_to include('hidden_requirement', 'private default', 'SetHiddenRequirement')
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
it "limits authorized-live #{role} metadata newly required for status #{success}" do
|
|
245
|
+
adapter
|
|
246
|
+
context.description = Marshal.load(Marshal.dump(description))
|
|
247
|
+
visible = context.description.dig(:resources, :client, :resources, :child, :actions, :client)
|
|
248
|
+
visible[:meta][role] = { input: GoDocumentationFixture.io({ confirm: { type: 'Boolean', required: true } }) }
|
|
249
|
+
sample[:status] = success
|
|
250
|
+
|
|
251
|
+
expect(render).to include(status: :limitation, reason: 'This action requires metadata that is absent from the sample.')
|
|
252
|
+
expect(render.to_s).not_to include('confirm', 'SetConfirm')
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
it 'preserves optional global guidance and allows optional object metadata without sample values' do
|
|
258
|
+
action[:meta][:global][:input][:parameters][:includes][:required] = false
|
|
259
|
+
action[:meta][:object] = { input: GoDocumentationFixture.io({ confirm: { type: 'Boolean', required: false } }) }
|
|
260
|
+
expect(render[:code]).to include('// metadata := invocation.NewMetaInput().SelectParameters()', '// metadata.SetIncludes(value)')
|
|
261
|
+
expect(render[:code]).not_to include('SetConfirm')
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
it 'keeps metadata-free success and failure examples executable' do
|
|
265
|
+
action[:meta] = {}
|
|
266
|
+
expect(render[:status]).to eq(:code)
|
|
267
|
+
expect(render[:code]).not_to include('NewMetaInput')
|
|
268
|
+
sample[:status] = false
|
|
269
|
+
expect(render[:code]).to include('if !response.Status')
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
it 'does not disclose hidden global metadata setters' do
|
|
273
|
+
adapter
|
|
274
|
+
context.description = Marshal.load(Marshal.dump(description))
|
|
275
|
+
context.description.dig(:resources, :client, :resources, :child, :actions, :client, :meta, :global, :input, :parameters).clear
|
|
276
|
+
expect(render[:code]).not_to include('NewMetaInput', 'SetIncludes')
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
it 'rejects Custom output, invalid path values, and ambiguous aliases safely' do
|
|
280
|
+
action[:output][:parameters][:payload] = { type: 'Custom' }
|
|
281
|
+
sample[:response][:payload] = { private_name: 'private value' }
|
|
282
|
+
expect(render).to include(status: :limitation, reason: /Custom/)
|
|
283
|
+
expect(render.to_s).not_to include('private_name', 'private value')
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
it 'keeps alias resolution unambiguous and canonical names preferred' do
|
|
287
|
+
resource = description.dig(:resources, :client, :resources, :child)
|
|
288
|
+
resource[:actions][:other] = GoDocumentationFixture.action(aliases: %w[perform client])
|
|
289
|
+
expect(adapter.resolve(resource_path: %w[client child], action_name: 'perform')).to be_nil
|
|
290
|
+
expect(adapter.resolve(resource_path: %w[client child], action_name: 'client')).not_to be_nil
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
it 'preserves partial and nullable Resource output while rejecting scalar association values' do
|
|
294
|
+
action[:output][:parameters][:owner] = { type: 'Resource', resource: ['owner'], value_id: 'id', value_label: 'label', nullable: true }
|
|
295
|
+
sample[:response][:owner] = { id: 9 }
|
|
296
|
+
expect(render[:code]).to include('if response.Output.Owner != nil', 'fmt.Println(response.Output.Owner.Id)')
|
|
297
|
+
expect(render[:code]).not_to include('Owner.Label')
|
|
298
|
+
sample[:response][:owner] = nil
|
|
299
|
+
expect(render[:code]).to include('fmt.Println(response.Output.Owner == nil)')
|
|
300
|
+
sample[:response][:owner] = 9
|
|
301
|
+
expect(render).to include(status: :limitation, reason: /requires an object/)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
it 'matches live Resource identifier symbols against a saved JSON contract' do
|
|
305
|
+
action[:output][:parameters][:owner] = { type: 'Resource', resource: ['owner'], value_id: :id, value_label: :label, nullable: true }
|
|
306
|
+
sample[:response][:owner] = { id: 9 }
|
|
307
|
+
saved = JSON.parse(JSON.generate(description))
|
|
308
|
+
json_adapter = described_class.new(description: saved, import_path: 'example.test/sdk/client', module_version: 'v1.2.3')
|
|
309
|
+
result = json_adapter.render(kind: :example, context:, mode: :sync)
|
|
310
|
+
|
|
311
|
+
expect(result[:status]).to eq(:code)
|
|
312
|
+
expect(result[:code]).to include('fmt.Println(response.Output.Owner.Id)')
|
|
313
|
+
expect(result[:code]).not_to include('Owner.Label')
|
|
314
|
+
expect(result[:code]).to eq(render.fetch(:code))
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
{
|
|
318
|
+
value_id: ['other', nil, false, 7, [], {}],
|
|
319
|
+
value_label: ['other', nil, false, 7, [], {}],
|
|
320
|
+
type: [:Resource], nullable: [false], resource: [[:owner]]
|
|
321
|
+
}.each do |key, values|
|
|
322
|
+
values.each do |value|
|
|
323
|
+
it "rejects a changed or invalid live Resource contract #{key}: #{value.inspect}" do
|
|
324
|
+
action[:output][:parameters][:owner] = { type: 'Resource', resource: ['owner'], value_id: 'id', value_label: 'label', nullable: true }
|
|
325
|
+
sample[:response][:owner] = { id: 9 }
|
|
326
|
+
adapter
|
|
327
|
+
action[:output][:parameters][:owner][key] = value
|
|
328
|
+
|
|
329
|
+
expect(render).to include(status: :limitation, reason: 'This response field does not match the configured Go SDK.')
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
it 'does not print associated fields missing from another documented list row' do
|
|
335
|
+
action[:output][:layout] = 'hash_list'
|
|
336
|
+
action[:output][:parameters][:owner] = { type: 'Resource', resource: ['owner'], value_id: 'id', value_label: 'label' }
|
|
337
|
+
sample[:response] = [{ owner: { id: 1, label: 'one' } }, { owner: { id: 2 } }]
|
|
338
|
+
expect(render[:code]).to include('fmt.Println(item.Owner.Id)')
|
|
339
|
+
expect(render[:code]).not_to include('Owner.Label')
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
it 'does not guess path argument types or wrap integers' do
|
|
343
|
+
sample[:path_params] = [2**64, 'child']
|
|
344
|
+
expect(render).to include(status: :limitation, reason: /int64 range/)
|
|
345
|
+
sample[:path_params] = [true, 'child']
|
|
346
|
+
expect(render).to include(status: :limitation, reason: /integer or string/)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
it 'rejects malformed profile imports and accepts JSON descriptions' do
|
|
350
|
+
expect do
|
|
351
|
+
described_class.new(description:, import_path: "bad\nsource", module_version: 'v1.0.0')
|
|
352
|
+
end.to raise_error(ArgumentError)
|
|
353
|
+
json_adapter = described_class.new(description: JSON.parse(JSON.generate(description)), import_path: 'example.test/sdk', module_version: 'v1.0.0')
|
|
354
|
+
expect(json_adapter.resolve(resource_path: %w[client child], action_name: 'client')).not_to be_nil
|
|
355
|
+
end
|
|
356
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: haveapi-go-client
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.30.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jakub Skokan
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 0.
|
|
18
|
+
version: 0.30.1
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: 0.
|
|
25
|
+
version: 0.30.1
|
|
26
26
|
description: Go client generator
|
|
27
27
|
email:
|
|
28
28
|
- jakub.skokan@vpsfree.cz
|
|
@@ -36,6 +36,7 @@ files:
|
|
|
36
36
|
- README.md
|
|
37
37
|
- Rakefile
|
|
38
38
|
- bin/haveapi-go-client
|
|
39
|
+
- docs/documentation.md
|
|
39
40
|
- haveapi-go-client.gemspec
|
|
40
41
|
- lib/haveapi/go_client.rb
|
|
41
42
|
- lib/haveapi/go_client/action.rb
|
|
@@ -47,6 +48,7 @@ files:
|
|
|
47
48
|
- lib/haveapi/go_client/authentication/unsupported.rb
|
|
48
49
|
- lib/haveapi/go_client/authentication_methods.rb
|
|
49
50
|
- lib/haveapi/go_client/cli.rb
|
|
51
|
+
- lib/haveapi/go_client/documentation.rb
|
|
50
52
|
- lib/haveapi/go_client/erb_template.rb
|
|
51
53
|
- lib/haveapi/go_client/generator.rb
|
|
52
54
|
- lib/haveapi/go_client/i18n_messages.yml
|
|
@@ -61,9 +63,17 @@ files:
|
|
|
61
63
|
- lib/haveapi/go_client/resource.rb
|
|
62
64
|
- lib/haveapi/go_client/utils.rb
|
|
63
65
|
- lib/haveapi/go_client/version.rb
|
|
66
|
+
- spec/documentation.Gemfile
|
|
67
|
+
- spec/documentation_acceptance.rb
|
|
68
|
+
- spec/documentation_build_check.rb
|
|
69
|
+
- spec/documentation_profile_check.rb
|
|
70
|
+
- spec/documentation_wire_check.rb
|
|
64
71
|
- spec/integration/generator_spec.rb
|
|
65
72
|
- spec/spec_helper.rb
|
|
73
|
+
- spec/support/documentation_fixture.rb
|
|
74
|
+
- spec/support/documentation_profile_fixture.rb
|
|
66
75
|
- spec/support/test_server.rb
|
|
76
|
+
- spec/unit/documentation_spec.rb
|
|
67
77
|
- template/action.go.erb
|
|
68
78
|
- template/authentication.go.erb
|
|
69
79
|
- template/authentication/basic.go.erb
|