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.
@@ -0,0 +1,273 @@
1
+ # Run after committed-deliverable review; this generates, compiles and executes SDKs.
2
+ require 'json'
3
+ require 'open3'
4
+ require 'tmpdir'
5
+ require 'tempfile'
6
+ require 'webrick'
7
+
8
+ require 'haveapi/go_client/documentation'
9
+ require 'haveapi/documentation_clients'
10
+ require 'haveapi/client_examples/go_client'
11
+ require_relative 'support/documentation_fixture'
12
+
13
+ class GoDocumentationAcceptance
14
+ def self.build_command(destination)
15
+ # The disposable consumer has no source history to stamp into its binaries.
16
+ ['go', 'build', '-buildvcs=false', '-o', destination, './cmd/...']
17
+ end
18
+
19
+ def self.expected_cases
20
+ # The SDK emits an empty metadata envelope for this metadata-bearing action.
21
+ object_body = { 'widget' => { 'name' => nil, 'enabled' => false, 'count' => 0 }, '_meta' => {} }
22
+ {
23
+ init: {}, basic: {}, token: {}, oauth2: {},
24
+ object: { body: object_body, output: '123' },
25
+ empty: { body: { 'widget' => {} } }, omitted: { body: { 'widget' => {} } },
26
+ list: { output: "1\n2" }, fail: { output: 'fixture failure' },
27
+ association: { output: '9' },
28
+ # The SDK encodes false/true query booleans as 0/1.
29
+ query: { query: { 'widget[name]' => '', 'widget[enabled]' => '0', 'widget[count]' => '0' } },
30
+ operation: { state: true },
31
+ basic_call: { body: object_body, auth: { authorization: "Basic #{['fixture-user:fixture-password'].pack('m0')}" } },
32
+ token_call: { body: object_body, auth: { token: 'fixture-token' } },
33
+ oauth2_call: { body: object_body, auth: { oauth2: 'fixture-access-token' } },
34
+ hostile: { body: { 'widget' => { 'name' => "quote\"\\\n雪</script>" }, '_meta' => {} }, output: '123' }
35
+ }
36
+ end
37
+
38
+ def self.run!
39
+ check_environment!
40
+ new.run!
41
+ end
42
+
43
+ # The same optional gems must be visible to Bundler and the product loader.
44
+ # This preflight performs no generation, compilation or server startup.
45
+ def self.check_environment!
46
+ %w[haveapi haveapi-go-client].each do |name|
47
+ raise "#{name} is absent from the acceptance bundle" unless Gem.loaded_specs.has_key?(name)
48
+ end
49
+ unless Gem.loaded_specs.fetch('haveapi-go-client').version.to_s == HaveAPI::GoClient::VERSION
50
+ raise 'activated Go generator specification does not match its loaded version'
51
+ end
52
+
53
+ Tempfile.create(['haveapi-go-documentation-profile-', '.json']) do |file|
54
+ file.write(JSON.generate(GoDocumentationFixture.description))
55
+ file.flush
56
+ profiles = HaveAPI::ClientExample::Profiles.new
57
+ profiles.register('7', go: {
58
+ import_path: 'example.test/sdk/client', module_version: 'v1.2.3', description_path: file.path,
59
+ generator_version: Gem.loaded_specs.fetch('haveapi-go-client').version.to_s
60
+ })
61
+ profiles.mount(['7'])
62
+ profiles.finalize(nil)
63
+ raise 'configured Go profile is unavailable' unless profiles.for_version('7').fetch(:go).language == :go
64
+ end
65
+ end
66
+
67
+ def initialize
68
+ @requests = Queue.new
69
+ @server = WEBrick::HTTPServer.new(
70
+ BindAddress: '127.0.0.1', Port: 0, AccessLog: [], Logger: WEBrick::Log.new(File::NULL)
71
+ )
72
+ @server.mount_proc('/') { |request, response| serve(request, response) }
73
+ end
74
+
75
+ def run!
76
+ thread = Thread.new { @server.start }
77
+ Dir.mktmpdir('haveapi-go-documentation-') do |dir|
78
+ prepare_sdk(dir)
79
+ prepare_programs(dir)
80
+ command('gofmt', '-w', File.join(dir, 'consumer'))
81
+ command(*self.class.build_command(File.join(dir, 'bin')), chdir: File.join(dir, 'consumer'))
82
+ @cases.each do |name, expected|
83
+ stdout = command(File.join(dir, 'bin', name.to_s), env: {
84
+ 'HAVEAPI_USER' => 'fixture-user', 'HAVEAPI_PASSWORD' => 'fixture-password',
85
+ 'HAVEAPI_TOKEN' => 'fixture-token', 'HAVEAPI_ACCESS_TOKEN' => 'fixture-access-token'
86
+ })
87
+ verify(name, expected, stdout)
88
+ end
89
+ end
90
+ # This is a standalone acceptance runner, not an RSpec example.
91
+ $stdout.write("Go documentation acceptance: #{@cases.length} rendered programs compiled and executed\n") # rubocop:disable RSpec/Output
92
+ ensure
93
+ @server.shutdown
94
+ thread&.join
95
+ end
96
+
97
+ private
98
+
99
+ def prepare_sdk(dir)
100
+ @description = GoDocumentationFixture.description
101
+ @description[:resources][:client][:resources][:child][:actions].merge!(
102
+ empty: GoDocumentationFixture.action(path: '/mounted/v7/empty', input: input),
103
+ omitted: GoDocumentationFixture.action(path: '/mounted/v7/omitted', input: input),
104
+ list: GoDocumentationFixture.action(path: '/mounted/v7/list', method: 'GET', output: output(layout: 'hash_list')),
105
+ association: GoDocumentationFixture.action(
106
+ path: '/mounted/v7/association', method: 'GET',
107
+ output: GoDocumentationFixture.io({ owner: { type: 'Resource', resource: ['owner'], value_id: 'id', value_label: 'label' } })
108
+ ),
109
+ fail: GoDocumentationFixture.action(path: '/mounted/v7/fail', method: 'GET'),
110
+ query: GoDocumentationFixture.action(path: '/mounted/v7/query', method: 'GET', input: input),
111
+ operation: GoDocumentationFixture.action(
112
+ path: '/mounted/v7/operation', method: 'GET', blocking: true,
113
+ meta: { global: { output: GoDocumentationFixture.io({ action_state_id: { type: 'Integer' } }, namespace: false) } }
114
+ )
115
+ )
116
+ # This exact local JSON is both generator input and the configured profile.
117
+ description_path = File.join(dir, 'sdk-description.json')
118
+ File.write(description_path, JSON.pretty_generate(@description))
119
+ saved = JSON.parse(File.read(description_path), symbolize_names: true)
120
+ generator = HaveAPI::GoClient::Generator.from_description(saved, File.join(dir, 'sdk'),
121
+ module: 'example.test/sdk', package: 'client')
122
+ generator.generate
123
+ profiles = HaveAPI::ClientExample::Profiles.new
124
+ profiles.register('7', go: {
125
+ import_path: 'example.test/sdk/client', module_version: 'v1.2.3', description_path:,
126
+ generator_version: HaveAPI::GoClient::VERSION
127
+ })
128
+ profiles.mount(['7'])
129
+ profiles.finalize(nil)
130
+ @profile = profiles.for_version('7').fetch(:go)
131
+ FileUtils.mkdir_p(File.join(dir, 'consumer'))
132
+ FileUtils.mkdir_p(File.join(dir, 'bin'))
133
+ File.write(File.join(dir, 'consumer/go.mod'), <<~MOD)
134
+ module example.test/documentation-consumer
135
+
136
+ go 1.20
137
+
138
+ require example.test/sdk v1.2.3
139
+ replace example.test/sdk => ../sdk
140
+ MOD
141
+ end
142
+
143
+ def prepare_programs(dir)
144
+ @cases = self.class.expected_cases
145
+ @cases.each_key do |name|
146
+ kind = if name == :init
147
+ :init
148
+ else
149
+ (%i[basic token oauth2].include?(name) ? :auth : :example)
150
+ end
151
+ action_name = %i[object basic_call token_call oauth2_call hostile].include?(name) ? :client : name
152
+ action = @description.dig(:resources, :client, :resources, :child, :actions, action_name)
153
+ sample = case name
154
+ when :object, :basic_call, :token_call, :oauth2_call then GoDocumentationFixture.sample
155
+ when :hostile then GoDocumentationFixture.sample.merge(request: { name: "quote\"\\\n雪</script>" })
156
+ when :empty then { request: {}, response: {}, status: true }
157
+ when :omitted then { response: {}, status: true }
158
+ when :list then { response: [{ id: 1 }, { id: 2 }], status: true }
159
+ when :association then { response: { owner: { id: 9 } }, status: true }
160
+ when :fail then { status: false }
161
+ when :query then { request: { name: nil, enabled: false, count: 0 }, status: true }
162
+ else { status: true }
163
+ end
164
+ context = HaveAPI::ClientExample::Context.new(
165
+ version: '7', host: '127.0.0.1', base_url: "http://127.0.0.1:#{@server.config[:Port]}/mounted/",
166
+ version_url: "http://127.0.0.1:#{@server.config[:Port]}/mounted/v7/", description: @description,
167
+ resource_path: %w[client child], action_name:, action:, sample:, profile: @profile
168
+ )
169
+ result = HaveAPI::ClientExamples::GoClient.render(kind, context:, auth_method: name, auth_description: {})
170
+ raise "#{name}: unexpected limitation: #{result[:reason]}" unless result[:status] == :code
171
+
172
+ path = File.join(dir, 'consumer/cmd', name.to_s)
173
+ FileUtils.mkdir_p(path)
174
+ # The complete program is the actual renderer result, without replacements.
175
+ source = result.fetch(:code)
176
+ if name.to_s.end_with?('_call')
177
+ auth = HaveAPI::ClientExamples::GoClient.render(:auth, context:, auth_method: name.to_s.delete_suffix('_call').to_sym, auth_description: {})
178
+ raise "#{name}: authentication limitation" unless auth[:status] == :code
179
+
180
+ source = compose(auth.fetch(:code), source)
181
+ end
182
+ File.write(File.join(path, 'main.go'), source)
183
+ end
184
+ end
185
+
186
+ def input
187
+ GoDocumentationFixture.io({ name: { type: 'String', nullable: true }, enabled: { type: 'Boolean' },
188
+ count: { type: 'Integer' }, unused: { type: 'String' } })
189
+ end
190
+
191
+ def output(layout: 'hash')
192
+ GoDocumentationFixture.io({ id: { type: 'Integer' }, name: { type: 'String', nullable: true } }, layout:)
193
+ end
194
+
195
+ def serve(request, response)
196
+ @requests << { path: request.path, query: request.query, raw_query: request.query_string, body: request.body,
197
+ auth: { authorization: request['Authorization'], token: request['X-Token'], oauth2: request['X-OAuth'] } }
198
+ response['Content-Type'] = 'application/json'
199
+ result = case request.path
200
+ when '/mounted/v7/fail'
201
+ { status: false, message: 'fixture failure', errors: { name: ['invalid'] } }
202
+ when '/mounted/v7/list'
203
+ { status: true, response: { widget: [{ id: 1 }, { id: 2 }] } }
204
+ when '/mounted/v7/association'
205
+ { status: true, response: { widget: { owner: { id: 9 } } } }
206
+ when '/mounted/v7/operation'
207
+ { status: true, response: { _meta: { action_state_id: 55 } } }
208
+ when '/mounted/v7/action_states/55'
209
+ { status: true, response: { action_state: { id: 55 } } }
210
+ else
211
+ { status: true, response: { widget: { id: 123 } } }
212
+ end
213
+ response.body = JSON.generate(result)
214
+ end
215
+
216
+ def verify(name, expected, stdout)
217
+ if %i[init basic token oauth2].include?(name)
218
+ raise "#{name}: initialization performed a request" unless @requests.empty?
219
+
220
+ return
221
+ end
222
+
223
+ request = @requests.pop(true)
224
+ expected_path = %i[object basic_call token_call oauth2_call hostile].include?(name) ? '/mounted/v7/clients/42/children/a/b' : "/mounted/v7/#{name}"
225
+ raise "#{name}: unexpected mounted/versioned path #{request[:path]}" unless request[:path] == expected_path
226
+ if expected[:body] && JSON.parse(request[:body]) != expected[:body]
227
+ raise "#{name}: supplied input or omission was not preserved: #{request[:body]}"
228
+ end
229
+
230
+ if expected[:query] && request[:query] != expected[:query]
231
+ raise "#{name}: query values were not preserved: expected #{expected[:query].inspect}, " \
232
+ "got #{request[:query].inspect}; raw query #{request[:raw_query].inspect}"
233
+ end
234
+
235
+ if expected[:auth] && !expected[:auth].all? { |key, value| request[:auth][key] == value }
236
+ raise "#{name}: rendered authentication did not reach the fixture"
237
+ end
238
+ raise "#{name}: unexpected rendered output #{stdout.inspect}" if expected[:output] && !stdout.include?(expected[:output])
239
+
240
+ if expected[:state]
241
+ state = @requests.pop(true)
242
+ raise 'operation: generated helper used the wrong route' unless state[:path] == '/mounted/v7/action_states/55'
243
+ end
244
+ raise "#{name}: unexpected extra request" unless @requests.empty?
245
+ end
246
+
247
+ # Compose exact rendered sections, deduplicating their shared initialization.
248
+ def compose(auth, action)
249
+ imports = [auth, action].flat_map { |source| source.match(/^import \(\n(.*?)\n\)/m)[1].lines(chomp: true) }.uniq
250
+ auth_body = auth.match(/^func main\(\) \{\n(.*)\n\}\n/m)[1].lines
251
+ action_body = action.match(/^func main\(\) \{\n(.*)\n\}\n/m)[1].lines
252
+ raise 'rendered initialization differs across sections' unless auth_body.first == action_body.first
253
+
254
+ [auth.split('import (', 2).first, "import (\n", imports.join("\n"), "\n)\n\nfunc main() {\n",
255
+ auth_body.join, "\n", action_body.drop(1).join, "\n}\n"].join
256
+ end
257
+
258
+ def command(*args, chdir: nil, env: {})
259
+ environment = { 'GOWORK' => 'off', 'GOPROXY' => 'off', 'GOSUMDB' => 'off', 'GOTOOLCHAIN' => 'local' }.merge(env)
260
+ stdout, stderr, status = Open3.capture3(environment, *args, **(chdir ? { chdir: } : {}))
261
+ raise "#{args.first} failed: #{stdout}\n#{stderr}" unless status.success?
262
+
263
+ stdout
264
+ end
265
+ end
266
+
267
+ if $0 == __FILE__
268
+ case ARGV
269
+ when [] then GoDocumentationAcceptance.run!
270
+ when ['--check-environment'] then GoDocumentationAcceptance.check_environment!
271
+ else raise ArgumentError, 'usage: documentation_acceptance.rb [--check-environment]'
272
+ end
273
+ end
@@ -0,0 +1,32 @@
1
+ # Native dry-run regression: no compiler, listener or generated SDK is started.
2
+ require_relative 'documentation_acceptance'
3
+
4
+ class GoDocumentationBuildCheck
5
+ def self.run!
6
+ GoDocumentationAcceptance.check_environment!
7
+ Dir.mktmpdir('haveapi-go-documentation-build-') do |root|
8
+ # Own this invalid ancestor marker; never rely on or alter ambient Git state.
9
+ Dir.mkdir(File.join(root, '.git'))
10
+ consumer = File.join(root, 'consumer')
11
+ FileUtils.mkdir_p(File.join(consumer, 'cmd/probe'))
12
+ File.write(File.join(consumer, 'go.mod'), "module example.test/documentation-consumer\n\ngo 1.20\n")
13
+ File.write(File.join(consumer, 'cmd/probe/main.go'), "package main\nfunc main() {}\n")
14
+ command = GoDocumentationAcceptance.build_command(File.join(root, 'bin')).insert(2, '-n', '-x')
15
+ environment = { 'GOWORK' => 'off', 'GOPROXY' => 'off', 'GOSUMDB' => 'off', 'GOTOOLCHAIN' => 'local' }
16
+ _output, error, status = Open3.capture3(environment, *command, chdir: consumer)
17
+ raise "documentation build plan failed: #{error}" unless status.success?
18
+ raise 'documentation build unexpectedly queried Git status' if error.include?('git status --porcelain')
19
+
20
+ # Prove the regression condition with the same command minus its policy flag.
21
+ control = command.reject { |argument| argument == '-buildvcs=false' }
22
+ _output, error, status = Open3.capture3(environment, *control, chdir: consumer)
23
+ unless !status.success? && error.include?('error obtaining VCS status') &&
24
+ error.include?("cd #{root}\n") && error.include?('git status --porcelain')
25
+ raise "unflagged control did not reproduce owned ancestor VCS discovery: #{error}"
26
+ end
27
+ end
28
+ $stdout.write("Go documentation build dry-run and unflagged VCS control passed\n") # rubocop:disable RSpec/Output
29
+ end
30
+ end
31
+
32
+ GoDocumentationBuildCheck.run! if $0 == __FILE__
@@ -0,0 +1,99 @@
1
+ # Pure mounted-DSL/profile regression. No generation, compiler or listener.
2
+ require 'json'
3
+ require 'tempfile'
4
+ require 'haveapi'
5
+ require 'haveapi/go_client/documentation'
6
+ require_relative 'support/documentation_profile_fixture'
7
+
8
+ class GoDocumentationProfileCheck
9
+ FIXTURE = GoDocumentationProfileFixture
10
+
11
+ def self.run!
12
+ new.run!
13
+ end
14
+
15
+ def run!
16
+ server = FIXTURE.server
17
+ profile = server.documentation_client_profiles('7').fetch(:go)
18
+ assert(profile.package_version.nil?, 'omitted module version must remain unresolved')
19
+ full = FIXTURE.description(server)
20
+ model = HaveAPI::GoClient::ApiVersion.new(full)
21
+ public_description = FIXTURE.description(server, user: :public)
22
+ privileged_description = FIXTURE.description(server, user: :privileged)
23
+ context = FIXTURE.context(public_description)
24
+ adapter = profile.adapter
25
+ structural = adapter.instance_variable_get(:@description).dig(:resources, :set_timeout_resource, :resources, :child, :actions, :client)
26
+ assert(!structural.has_key?(:examples), 'the private model must not retain example payloads')
27
+ assert(!structural.dig(:input, :parameters, :private_value).has_key?(:default), 'the private model must not retain parameter defaults')
28
+ resolved = adapter.resolve(resource_path: context.resource_path, action_name: 'perform')
29
+ canonical = model.resources.find { |resource| resource.name == 'set_timeout_resource' }
30
+ assert(resolved.resource.parent.go_member_name == canonical.go_member_name, 'full-DSL sibling allocation must be canonical')
31
+ assert(canonical.go_member_name != 'SetTimeoutResource', 'hidden reserved-name sibling must affect allocation')
32
+ assert(resolved.action.go_member_name == 'ClientAction', 'nested action must keep the generator collision name')
33
+
34
+ source = code(adapter, context)
35
+ assert(source.start_with?("// go get example.test/current-sdk/client@latest\n"), 'omitted version must emit valid latest selection')
36
+ assert(source.include?('client "example.test/current-sdk/client"'), 'SDK import alias must stay explicit')
37
+ assert(source.include?("api.#{canonical.go_member_name}.Child.ClientAction.Prepare()"), 'rendered nested members must use the full model')
38
+ assert(source.include?('SetPathParamString("parent_id", "parent/with space")'), 'ordered string path argument was lost')
39
+ assert(source.include?('SetPathParamInt("child_id", 0)'), 'ordered zero path argument was lost')
40
+ assert(source.include?('input := invocation.NewInput().SelectParameters()'), 'input omission selection must stay explicit')
41
+ assert(source.include?('input.SetEnabled(false)') && source.include?('input.SetCount(0)'), 'false/zero inputs were lost')
42
+ assert(source.include?("input.SetName(#{JSON.generate(FIXTURE::HOSTILE)})"), 'hostile source literal was not escaped')
43
+ assert(source.include?('response.Output.Owner.Id') && !source.include?('Owner.Label'), 'nested output must use only supplied attributes')
44
+ assert(source.include?('// metadata.SetIncludes(value)'), 'advertised optional includes guidance must remain')
45
+ assert(!source.match?(/private sample|private result|private default|SetPrivateValue|SetUnused|\.PrivateValue/), 'filtered fields or values leaked')
46
+
47
+ privileged = code(adapter, FIXTURE.context(privileged_description))
48
+ assert(privileged.include?('input.SetPrivateValue("private sample")'), 'authorized supplied field was lost')
49
+ assert(code(adapter, FIXTURE.context(public_description)) == source, 'sequential users must not change the shared model')
50
+ hidden = full.fetch(:resources).fetch('set_timeout').fetch(:actions).fetch('secret')
51
+ hidden_context = context.with(resource_path: ['set_timeout'], action_name: 'secret', action: hidden,
52
+ sample: { status: true, response: { private_value: 'must not leak' } })
53
+ limited(adapter, hidden_context, 'unavailable')
54
+
55
+ root_context = FIXTURE.context(public_description, path: ['set_timeout_resource'], action_name: 'child')
56
+ root_source = code(adapter, root_context)
57
+ assert(root_source.include?("api.#{canonical.go_member_name}.ChildAction.Prepare()"), 'resource/action collision was reconstructed incorrectly')
58
+ assert(root_source.include?('input.SetNameNil(true)'), 'nullable supplied input must use the nil setter')
59
+
60
+ pinned = FIXTURE.server(module_version: 'v2.3.4', generator_version: HaveAPI::GoClient::VERSION)
61
+ pinned_adapter = pinned.documentation_client_profiles('7').fetch(:go).adapter
62
+ pinned_source = code(pinned_adapter, FIXTURE.context(FIXTURE.description(pinned, user: :public)))
63
+ assert(pinned_source == source.sub('@latest', '@v2.3.4'), 'explicit version must preserve all other rendering')
64
+ check_legacy(full, source)
65
+ $stdout.write("Go documentation profiles: current mounted DSL, optional/pinned versions, legacy data and authorized rendering passed\n") # rubocop:disable RSpec/Output
66
+ end
67
+
68
+ private
69
+
70
+ def check_legacy(full, current_source)
71
+ Tempfile.create(['haveapi-go-profile-', '.json']) do |file|
72
+ file.write(JSON.generate(full))
73
+ file.flush
74
+ server = FIXTURE.server(description_path: file.path, module_version: 'v2.3.4',
75
+ generator_version: HaveAPI::GoClient::VERSION)
76
+ adapter = server.documentation_client_profiles('7').fetch(:go).adapter
77
+ context = FIXTURE.context(FIXTURE.description(server, user: :public))
78
+ assert(code(adapter, context) == current_source.sub('@latest', '@v2.3.4'), 'legacy saved profile must preserve current matching rendering')
79
+ end
80
+ end
81
+
82
+ def code(adapter, context)
83
+ result = adapter.render(kind: :example, context:, mode: :sync)
84
+ assert(result[:status] == :code, "unexpected limitation: #{result[:reason]}")
85
+ result.fetch(:code)
86
+ end
87
+
88
+ def limited(adapter, context, reason)
89
+ result = adapter.render(kind: :example, context:, mode: :sync)
90
+ assert(result[:status] == :limitation && result[:reason].include?(reason), 'hidden action must remain unavailable')
91
+ assert(!result.to_s.include?('must not leak'), 'limitation disclosed the hidden sample')
92
+ end
93
+
94
+ def assert(condition, message)
95
+ raise message unless condition
96
+ end
97
+ end
98
+
99
+ GoDocumentationProfileCheck.run! if $0 == __FILE__
@@ -0,0 +1,94 @@
1
+ # Pure verifier regression: no generator, compiler or listener is started.
2
+ require 'stringio'
3
+ require 'uri'
4
+ require_relative 'documentation_acceptance'
5
+
6
+ class GoDocumentationWireCheck
7
+ OBJECT_BODY = JSON.parse('{"widget":{"count":0,"enabled":false,"name":null},"_meta":{}}').freeze
8
+ OBJECT_CASES = %i[object basic_call token_call oauth2_call].freeze
9
+
10
+ def self.run!
11
+ OBJECT_CASES.each do |name|
12
+ verify(name, OBJECT_BODY)
13
+ reject_change(name) { |body| body['_meta'] = { 'includes' => 'unrequested' } }
14
+ reject_change(name) { |body| body['_meta'] = nil }
15
+ reject_change(name) { |body| body.delete('_meta') }
16
+ reject_change(name) { |body| body['extra'] = 'unrequested' }
17
+ reject_change(name) { |body| body['widget']['unused'] = '' }
18
+ reject_change(name) { |body| body['widget']['enabled'] = true }
19
+ reject_change(name) { |body| body['widget']['count'] = 1 }
20
+ reject_change(name) { |body| body['widget']['name'] = '' }
21
+ %w[name enabled count].each do |field|
22
+ reject_change(name) { |body| body['widget'].delete(field) }
23
+ end
24
+ end
25
+
26
+ hostile = { 'widget' => { 'name' => "quote\"\\\n雪</script>" }, '_meta' => {} }
27
+ verify(:hostile, hostile)
28
+ reject_change(:hostile, hostile) { |body| body['_meta']['includes'] = 'unrequested' }
29
+ reject_change(:hostile, hostile) { |body| body['widget']['unused'] = '' }
30
+
31
+ %i[empty omitted].each do |name|
32
+ body = { 'widget' => {} }
33
+ verify(name, body)
34
+ reject_change(name, body) { |changed| changed['_meta'] = {} }
35
+ reject_change(name, body) { |changed| changed['widget']['unused'] = '' }
36
+ reject_change(name, body) { |changed| changed.delete('widget') }
37
+ end
38
+ check_query
39
+ $stdout.write("Go documentation wire verifier: 7 accepted bodies and 52 rejected mutations\n") # rubocop:disable RSpec/Output
40
+ $stdout.write("Go documentation query verifier: 1 accepted query and 8 rejected mutations\n") # rubocop:disable RSpec/Output
41
+ end
42
+
43
+ def self.check_query
44
+ verify_query('widget%5Bcount%5D=0&widget%5Benabled%5D=0&widget%5Bname%5D=')
45
+ original = { 'widget[name]' => '', 'widget[enabled]' => '0', 'widget[count]' => '0' }
46
+ [
47
+ original.merge('widget[enabled]' => 'false'),
48
+ original.merge('widget[enabled]' => '1'),
49
+ original.merge('widget[count]' => '1'),
50
+ original.merge('widget[name]' => 'null'),
51
+ *original.keys.map { |key| original.reject { |name, _value| name == key } },
52
+ original.merge('widget[unused]' => '')
53
+ ].each { |query| reject_query(URI.encode_www_form(query)) }
54
+ end
55
+
56
+ def self.verify_query(raw_query)
57
+ request = WEBrick::HTTPRequest.new(WEBrick::Config::HTTP)
58
+ request.parse(StringIO.new("GET /mounted/v7/query?#{raw_query} HTTP/1.1\r\nHost: fixture.invalid\r\n\r\n"))
59
+ response = WEBrick::HTTPResponse.new(WEBrick::Config::HTTP)
60
+ runner = GoDocumentationAcceptance.allocate
61
+ runner.instance_variable_set(:@requests, Queue.new)
62
+ runner.send(:serve, request, response)
63
+ runner.send(:verify, :query, GoDocumentationAcceptance.expected_cases.fetch(:query), '')
64
+ end
65
+
66
+ def self.reject_query(raw_query)
67
+ verify_query(raw_query)
68
+ raise 'query: verifier accepted an unexpected query'
69
+ rescue RuntimeError => e
70
+ raise unless e.message.start_with?('query: query values were not preserved: expected ') &&
71
+ e.message.include?('got ') && e.message.include?("raw query #{raw_query.inspect}")
72
+ end
73
+
74
+ def self.verify(name, body)
75
+ expected = GoDocumentationAcceptance.expected_cases.fetch(name)
76
+ runner = GoDocumentationAcceptance.allocate
77
+ requests = Queue.new
78
+ path = %i[empty omitted].include?(name) ? "/mounted/v7/#{name}" : '/mounted/v7/clients/42/children/a/b'
79
+ requests << { path:, body: JSON.generate(body), auth: expected.fetch(:auth, {}) }
80
+ runner.instance_variable_set(:@requests, requests)
81
+ runner.send(:verify, name, expected, expected.fetch(:output, ''))
82
+ end
83
+
84
+ def self.reject_change(name, original = OBJECT_BODY)
85
+ body = Marshal.load(Marshal.dump(original))
86
+ yield(body)
87
+ verify(name, body)
88
+ raise "#{name}: verifier accepted an unexpected body"
89
+ rescue RuntimeError => e
90
+ raise unless e.message.start_with?("#{name}: supplied input or omission was not preserved:")
91
+ end
92
+ end
93
+
94
+ GoDocumentationWireCheck.run! if $0 == __FILE__
@@ -0,0 +1,70 @@
1
+ # Saved SDK schema fixture. Generated Go source is always produced by Generator.
2
+ module GoDocumentationFixture
3
+ def self.io(parameters, layout: 'hash', namespace: 'widget')
4
+ { layout:, namespace:, parameters: }
5
+ end
6
+
7
+ def self.action(path: '/mounted/v7/widgets', method: 'POST', input: nil, output: nil, aliases: [], blocking: false, meta: {})
8
+ { path:, method:, input:, output:, aliases:, blocking:, meta: }
9
+ end
10
+
11
+ def self.token
12
+ output = io({ complete: { type: 'Boolean' }, token: { type: 'String' }, next_action: { type: 'String' } }, namespace: 'token')
13
+ {
14
+ http_header: 'X-Token', query_parameter: 'token',
15
+ resources: {
16
+ token: {
17
+ resources: {},
18
+ actions: {
19
+ request: action(path: '/mounted/v7/tokens', input: io({ user: { type: 'String' }, password: { type: 'String' },
20
+ lifetime: { type: 'String' }, interval: { type: 'Integer' } }), output:),
21
+ challenge: action(path: '/mounted/v7/tokens/challenge', input: io({ token: { type: 'String' }, code: { type: 'String' } }), output:),
22
+ revoke: action(path: '/mounted/v7/tokens/revoke')
23
+ }
24
+ }
25
+ }
26
+ }
27
+ end
28
+
29
+ def self.description
30
+ state = {
31
+ resources: {},
32
+ actions: {
33
+ show: action(path: '/mounted/v7/action_states/{action_state_id}', method: 'GET',
34
+ output: io({ id: { type: 'Integer' } }, namespace: 'action_state')),
35
+ poll: action(path: '/mounted/v7/action_states/{action_state_id}/poll', method: 'GET',
36
+ input: io({ timeout: { type: 'Float' }, update_in: { type: 'Float' },
37
+ status: { type: 'String' }, current: { type: 'Integer' }, total: { type: 'Integer' } }),
38
+ output: io({ finished: { type: 'Boolean' }, status: { type: 'String' },
39
+ current: { type: 'Integer' }, total: { type: 'Integer' } }, namespace: 'action_state')),
40
+ cancel: action(path: '/mounted/v7/action_states/{action_state_id}/cancel', method: 'POST')
41
+ }
42
+ }
43
+ run = action(
44
+ path: '/mounted/v7/clients/{client_id}/children/{child_id}', aliases: ['perform'],
45
+ input: io({ name: { type: 'String', nullable: true }, enabled: { type: 'Boolean' },
46
+ count: { type: 'Integer' }, unused: { type: 'String' } }),
47
+ output: io({ id: { type: 'Integer' }, name: { type: 'String', nullable: true } }),
48
+ meta: { global: { input: io({ includes: { type: 'Custom' } }, namespace: false) } }
49
+ )
50
+ {
51
+ authentication: { basic: {}, token: token, oauth2: { http_header: 'X-OAuth', revoke_url: '/revoke' } },
52
+ meta: { namespace: '_meta' },
53
+ resources: {
54
+ action_state: state,
55
+ owner: { resources: {}, actions: {
56
+ show: action(path: '/mounted/v7/owners/{owner_id}', method: 'GET',
57
+ output: io({ id: { type: 'Integer' }, label: { type: 'String' } }, namespace: 'owner'))
58
+ } },
59
+ client: { resources: { child: { resources: {}, actions: { client: run } } }, actions: {} },
60
+ authentication: { resources: {}, actions: {} },
61
+ hidden: { resources: {}, actions: { secret: action } }
62
+ }
63
+ }
64
+ end
65
+
66
+ def self.sample
67
+ { path_params: [42, 'a/b'], request: { name: nil, enabled: false, count: 0 },
68
+ response: { id: 1 }, status: true }
69
+ end
70
+ end