haveapi-go-client 0.29.9 → 0.30.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.
@@ -0,0 +1,286 @@
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 'resolves canonical generator members and aliases without generator or serving I/O' do
19
+ allow(HaveAPI::GoClient::Generator).to receive(:new).and_call_original
20
+ allow(HaveAPI::GoClient::Generator).to receive(:allocate).and_call_original
21
+ allow(File).to receive(:read).and_call_original
22
+ model = HaveAPI::GoClient::ApiVersion.new(GoDocumentationFixture.description)
23
+ resolved = adapter.resolve(resource_path: %w[client child], action_name: 'perform')
24
+ canonical = model.resources.find { |resource| resource.name == 'client' }.resources.first.actions.first
25
+
26
+ expect(resolved.action.go_member_names).to eq(canonical.go_member_names)
27
+ expect(resolved.resource.resource_path.map(&:go_member_name)).to eq(%w[Client Child])
28
+ expect(resolved.action.go_member_name).to eq('ClientAction')
29
+ expect(render[:status]).to eq(:code)
30
+ expect(HaveAPI::GoClient::Generator).not_to have_received(:new)
31
+ expect(HaveAPI::GoClient::Generator).not_to have_received(:allocate)
32
+ expect(File).not_to have_received(:read)
33
+ end
34
+
35
+ it 'uses the full saved model even when the visible description has fewer siblings' do
36
+ description[:resources][:set_timeout] = { resources: {}, actions: {} }
37
+ adapter
38
+ context.description = Marshal.load(Marshal.dump(description))
39
+ context.description[:resources].delete(:hidden)
40
+ context.description[:resources].delete(:set_timeout)
41
+
42
+ expect(adapter.resolve(resource_path: ['set_timeout'], action_name: 'missing')).to be_nil
43
+ expect(render[:code]).not_to include('hidden', 'secret', 'SetTimeout')
44
+ end
45
+
46
+ it 'selects no inputs before supplied setters, preserving null, omission, false and zero' do
47
+ result = render
48
+ expect(result).to be_frozen
49
+ expect(result[:code]).to include('client "example.test/sdk/client"', 'client.New("https://api.example.test")',
50
+ 'api.Client.Child.ClientAction.Prepare()',
51
+ 'invocation.SetPathParamInt("client_id", 42)',
52
+ 'invocation.SetPathParamString("child_id", "a/b")',
53
+ 'input := invocation.NewInput().SelectParameters()', 'input.SetNameNil(true)',
54
+ 'input.SetEnabled(false)', 'input.SetCount(0)')
55
+ expect(result[:code]).not_to include('SetUnused')
56
+ expect(result[:code].index('SelectParameters()')).to be < result[:code].index('input.SetNameNil')
57
+ end
58
+
59
+ it 'preserves an explicitly empty request and escapes source literals' do
60
+ sample[:request] = {}
61
+ expect(render[:code]).to include('invocation.NewInput().SelectParameters()')
62
+ expect(render[:code]).not_to include('input :=')
63
+ sample[:request] = { name: "quote\"\\\n雪</script>" }
64
+ expect(render[:code]).to include('input.SetName("quote\\"\\\\\\n雪</script>")')
65
+ end
66
+
67
+ it 'rejects list input, Custom fields, invalid values, missing paths and stale contracts' do
68
+ sample[:request][:count] = 'wrong'
69
+ expect(render[:status]).to eq(:limitation)
70
+ sample[:request][:count] = 0
71
+ sample[:path_params].pop
72
+ expect(render[:reason]).to include('path parameters')
73
+ sample[:path_params] << 'child'
74
+ action[:input][:parameters][:name][:nullable] = false
75
+ expect(render[:status]).to eq(:limitation)
76
+ end
77
+
78
+ it 'reports a list-input limitation rather than discarding fields' do
79
+ action[:input][:layout] = 'hash_list'
80
+ sample[:request] = [{ name: 'a' }]
81
+ expect(render).to include(status: :limitation, reason: /list input/)
82
+ end
83
+
84
+ it 'reports unsupported Custom input and output without rendering its contents' do
85
+ action[:input][:parameters][:custom] = { type: 'Custom' }
86
+ sample[:request][:custom] = { secret_value: 'do not render' }
87
+ expect(render).to include(status: :limitation, reason: /Custom/)
88
+ expect(render.to_s).not_to include('secret_value', 'do not render')
89
+ end
90
+
91
+ it 'supports list output and refuses scalar null output fidelity claims' do
92
+ action[:output][:layout] = 'hash_list'
93
+ sample[:response] = [{ id: 1 }, { id: 2 }]
94
+ expect(render[:code]).to include('for _, item := range response.Output', 'fmt.Println(item.Id)')
95
+ sample[:response] = [{ name: nil }]
96
+ expect(render).to include(status: :limitation, reason: /scalar null/)
97
+ end
98
+
99
+ it 'renders transport errors and API failures separately without invented success output' do
100
+ sample[:status] = false
101
+ sample[:response] = nil
102
+ expect(render[:code]).to include('if err != nil', 'if !response.Status', 'response.Message, response.Errors')
103
+ expect(render[:code]).not_to include('response.Output')
104
+ end
105
+
106
+ it 'never exposes a saved action absent from the authorized description' do
107
+ adapter
108
+ context.description = Marshal.load(Marshal.dump(description))
109
+ context.description[:resources].delete(:client)
110
+ expect(render).to include(status: :limitation)
111
+ expect(render.to_s).not_to include('secret', 'hidden')
112
+ end
113
+
114
+ it 'checks response schema drift only on fields in the filtered sample' do
115
+ adapter
116
+ context.description = Marshal.load(Marshal.dump(description))
117
+ context.description.dig(:resources, :client, :resources, :child, :actions, :client, :output, :parameters, :id)[:type] = 'String'
118
+ expect(render).to include(status: :limitation, reason: /response field/)
119
+ end
120
+
121
+ it 'only renders advertised authentication and uses environment placeholders' do
122
+ basic = adapter.render(kind: :auth, context:, mode: :sync, auth_method: :basic, auth_description: {})
123
+ expect(basic[:code]).to include('api.SetBasicAuthentication(os.Getenv("HAVEAPI_USER"), os.Getenv("HAVEAPI_PASSWORD"))')
124
+ oauth = adapter.render(kind: :auth, context:, mode: :sync, auth_method: :oauth2, auth_description: {})
125
+ expect(oauth[:code]).to include('api.SetExistingOAuth2Auth(os.Getenv("HAVEAPI_ACCESS_TOKEN"))')
126
+ token = adapter.render(kind: :auth, context:, mode: :sync, auth_method: :token, auth_description: {})
127
+ expect(token[:code]).to include('api.SetExistingTokenAuth(os.Getenv("HAVEAPI_TOKEN"))')
128
+ context.description = Marshal.load(Marshal.dump(description))
129
+ context.description[:authentication].delete(:token)
130
+ expect(adapter.render(kind: :auth, context:, mode: :sync, auth_method: :token, auth_description: {})).to include(status: :limitation)
131
+ end
132
+
133
+ it 'does not show operation helpers when action-state visibility is absent' do
134
+ action[:blocking] = true
135
+ action[:meta][:global][:output] = GoDocumentationFixture.io({ action_state_id: { type: 'Integer' } }, namespace: false)
136
+ adapter
137
+ context.description = Marshal.load(Marshal.dump(description))
138
+ context.description[:resources].delete(:action_state)
139
+ expect(render[:code]).not_to include('response.OperationStatus')
140
+ expect(render[:code]).to include('does not establish operation completion')
141
+ end
142
+
143
+ it 'uses the origin with mounted versioned SDK paths and rejects a changed mount' do
144
+ expect(render[:code]).to include('client.New("https://api.example.test")')
145
+ expect(adapter.resolve(resource_path: %w[client child], action_name: 'client').action.path).to start_with('/mounted/v7/')
146
+ adapter
147
+ context.description = Marshal.load(Marshal.dump(description))
148
+ context.description.dig(:resources, :client, :resources, :child, :actions, :client)[:path] = '/other/v7/widgets'
149
+ expect(render).to include(status: :limitation, reason: /route/)
150
+ end
151
+
152
+ it 'preserves the URL constructor and its authentication and generation options' do
153
+ connection = instance_double(HaveAPI::Client::Communicator, describe_api: description, authenticate: nil)
154
+ allow(HaveAPI::Client::Communicator).to receive(:new).with('https://fixture.invalid').and_return(connection)
155
+ generator = HaveAPI::GoClient::Generator.new('https://fixture.invalid', '/unused',
156
+ version: '7', module: 'example.test/sdk', package: 'client',
157
+ basic_user: 'fixture-user', basic_password: 'fixture-password')
158
+ expect(connection).to have_received(:authenticate).with(:basic, user: 'fixture-user', password: 'fixture-password')
159
+ expect(connection).to have_received(:describe_api).with('7')
160
+ expect(generator.dst).to eq('/unused')
161
+ expect(generator.module).to eq('example.test/sdk')
162
+ expect(generator.package).to eq('client')
163
+ end
164
+
165
+ it 'builds a fixture generator from saved input without fetching or writing' do
166
+ allow(HaveAPI::Client::Communicator).to receive(:new).and_call_original
167
+ allow(FileUtils).to receive(:mkpath).and_call_original
168
+ generator = HaveAPI::GoClient::Generator.from_description(description, '/unused', module: 'example.test/sdk', package: 'client')
169
+ expect(generator.dst).to eq('/unused')
170
+ expect(HaveAPI::Client::Communicator).not_to have_received(:new)
171
+ expect(FileUtils).not_to have_received(:mkpath)
172
+ end
173
+
174
+ it 'rejects malformed saved schemas at initialization' do
175
+ description[:resources][:client][:resources][:child][:actions][:client][:path] = nil
176
+ expect { adapter }.to raise_error(ArgumentError, /saved Go SDK action/)
177
+ end
178
+
179
+ it 'checks Go field and method name collisions' do
180
+ action[:input][:parameters][:select_parameters] = { type: 'String' }
181
+ expect(render).to include(status: :limitation, reason: /name collisions/)
182
+ end
183
+
184
+ it 'shows operation methods only for visible matching state actions and metadata' do
185
+ action[:blocking] = true
186
+ action[:meta][:global][:output] = GoDocumentationFixture.io({ action_state_id: { type: 'Integer' } }, namespace: false)
187
+ expect(render[:code]).to include('response.IsBlocking()', 'response.OperationStatus()')
188
+ end
189
+
190
+ %i[global object].each do |role|
191
+ [true, false].each do |success|
192
+ it "limits #{role} metadata required in the saved SDK for status #{success}" do
193
+ action[:meta][role] = { input: GoDocumentationFixture.io({ hidden_requirement: { type: 'Boolean', required: true, default: 'private default' } }) }
194
+ sample[:status] = success
195
+ adapter
196
+ context.description = Marshal.load(Marshal.dump(description))
197
+ visible = context.description.dig(:resources, :client, :resources, :child, :actions, :client)
198
+ visible[:meta].delete(role)
199
+
200
+ expect(render).to include(status: :limitation, reason: 'This action requires metadata that is absent from the sample.')
201
+ expect(render.to_s).not_to include('hidden_requirement', 'private default', 'SetHiddenRequirement')
202
+ end
203
+
204
+ it "limits authorized-live #{role} metadata newly required for status #{success}" do
205
+ adapter
206
+ context.description = Marshal.load(Marshal.dump(description))
207
+ visible = context.description.dig(:resources, :client, :resources, :child, :actions, :client)
208
+ visible[:meta][role] = { input: GoDocumentationFixture.io({ confirm: { type: 'Boolean', required: true } }) }
209
+ sample[:status] = success
210
+
211
+ expect(render).to include(status: :limitation, reason: 'This action requires metadata that is absent from the sample.')
212
+ expect(render.to_s).not_to include('confirm', 'SetConfirm')
213
+ end
214
+ end
215
+ end
216
+
217
+ it 'preserves optional global guidance and allows optional object metadata without sample values' do
218
+ action[:meta][:global][:input][:parameters][:includes][:required] = false
219
+ action[:meta][:object] = { input: GoDocumentationFixture.io({ confirm: { type: 'Boolean', required: false } }) }
220
+ expect(render[:code]).to include('// metadata := invocation.NewMetaInput().SelectParameters()', '// metadata.SetIncludes(value)')
221
+ expect(render[:code]).not_to include('SetConfirm')
222
+ end
223
+
224
+ it 'keeps metadata-free success and failure examples executable' do
225
+ action[:meta] = {}
226
+ expect(render[:status]).to eq(:code)
227
+ expect(render[:code]).not_to include('NewMetaInput')
228
+ sample[:status] = false
229
+ expect(render[:code]).to include('if !response.Status')
230
+ end
231
+
232
+ it 'does not disclose hidden global metadata setters' do
233
+ adapter
234
+ context.description = Marshal.load(Marshal.dump(description))
235
+ context.description.dig(:resources, :client, :resources, :child, :actions, :client, :meta, :global, :input, :parameters).clear
236
+ expect(render[:code]).not_to include('NewMetaInput', 'SetIncludes')
237
+ end
238
+
239
+ it 'rejects Custom output, invalid path values, and ambiguous aliases safely' do
240
+ action[:output][:parameters][:payload] = { type: 'Custom' }
241
+ sample[:response][:payload] = { private_name: 'private value' }
242
+ expect(render).to include(status: :limitation, reason: /Custom/)
243
+ expect(render.to_s).not_to include('private_name', 'private value')
244
+ end
245
+
246
+ it 'keeps alias resolution unambiguous and canonical names preferred' do
247
+ resource = description.dig(:resources, :client, :resources, :child)
248
+ resource[:actions][:other] = GoDocumentationFixture.action(aliases: %w[perform client])
249
+ expect(adapter.resolve(resource_path: %w[client child], action_name: 'perform')).to be_nil
250
+ expect(adapter.resolve(resource_path: %w[client child], action_name: 'client')).not_to be_nil
251
+ end
252
+
253
+ it 'preserves partial and nullable Resource output while rejecting scalar association values' do
254
+ action[:output][:parameters][:owner] = { type: 'Resource', resource: ['owner'], value_id: 'id', value_label: 'label', nullable: true }
255
+ sample[:response][:owner] = { id: 9 }
256
+ expect(render[:code]).to include('if response.Output.Owner != nil', 'fmt.Println(response.Output.Owner.Id)')
257
+ expect(render[:code]).not_to include('Owner.Label')
258
+ sample[:response][:owner] = nil
259
+ expect(render[:code]).to include('fmt.Println(response.Output.Owner == nil)')
260
+ sample[:response][:owner] = 9
261
+ expect(render).to include(status: :limitation, reason: /requires an object/)
262
+ end
263
+
264
+ it 'does not print associated fields missing from another documented list row' do
265
+ action[:output][:layout] = 'hash_list'
266
+ action[:output][:parameters][:owner] = { type: 'Resource', resource: ['owner'], value_id: 'id', value_label: 'label' }
267
+ sample[:response] = [{ owner: { id: 1, label: 'one' } }, { owner: { id: 2 } }]
268
+ expect(render[:code]).to include('fmt.Println(item.Owner.Id)')
269
+ expect(render[:code]).not_to include('Owner.Label')
270
+ end
271
+
272
+ it 'does not guess path argument types or wrap integers' do
273
+ sample[:path_params] = [2**64, 'child']
274
+ expect(render).to include(status: :limitation, reason: /int64 range/)
275
+ sample[:path_params] = [true, 'child']
276
+ expect(render).to include(status: :limitation, reason: /integer or string/)
277
+ end
278
+
279
+ it 'rejects malformed profile imports and accepts JSON descriptions' do
280
+ expect do
281
+ described_class.new(description:, import_path: "bad\nsource", module_version: 'v1.0.0')
282
+ end.to raise_error(ArgumentError)
283
+ json_adapter = described_class.new(description: JSON.parse(JSON.generate(description)), import_path: 'example.test/sdk', module_version: 'v1.0.0')
284
+ expect(json_adapter.resolve(resource_path: %w[client child], action_name: 'client')).not_to be_nil
285
+ end
286
+ 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.29.9
4
+ version: 0.30.0
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.29.9
18
+ version: 0.30.0
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.29.9
25
+ version: 0.30.0
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,15 @@ 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_wire_check.rb
64
70
  - spec/integration/generator_spec.rb
65
71
  - spec/spec_helper.rb
72
+ - spec/support/documentation_fixture.rb
66
73
  - spec/support/test_server.rb
74
+ - spec/unit/documentation_spec.rb
67
75
  - template/action.go.erb
68
76
  - template/authentication.go.erb
69
77
  - template/authentication/basic.go.erb