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,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
|