haveapi-typescript-client 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Gemfile +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +204 -0
- data/Rakefile +8 -0
- data/bin/haveapi-typescript-client +5 -0
- data/docs/documentation.md +104 -0
- data/haveapi-typescript-client.gemspec +18 -0
- data/lib/haveapi/typescript_client/cli.rb +41 -0
- data/lib/haveapi/typescript_client/documentation.rb +316 -0
- data/lib/haveapi/typescript_client/emitter.rb +239 -0
- data/lib/haveapi/typescript_client/generator.rb +176 -0
- data/lib/haveapi/typescript_client/ir.rb +202 -0
- data/lib/haveapi/typescript_client/naming.rb +38 -0
- data/lib/haveapi/typescript_client/snapshot.rb +32 -0
- data/lib/haveapi/typescript_client/version.rb +5 -0
- data/lib/haveapi/typescript_client.rb +12 -0
- data/template/action.ts.erb +13 -0
- data/template/api.ts.erb +28 -0
- data/template/index.ts.erb +3 -0
- data/template/resource.ts.erb +30 -0
- data/template/runtime.ts.erb +169 -0
- data/template/types.ts.erb +4 -0
- metadata +73 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
require 'pathname'
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
|
|
5
|
+
module HaveAPI::TypeScriptClient
|
|
6
|
+
class Generator
|
|
7
|
+
ROOT = File.expand_path('../../..', __dir__)
|
|
8
|
+
OWNER_FILE = '.haveapi-generated.json'.freeze
|
|
9
|
+
OWNER = 'haveapi-typescript-client'.freeze
|
|
10
|
+
CONFIG = { runtime_import: 'haveapi-client' }.freeze
|
|
11
|
+
|
|
12
|
+
def self.source_files
|
|
13
|
+
Dir.chdir(ROOT) do
|
|
14
|
+
Dir['bin/**/*', 'lib/**/*.rb', 'template/**/*', 'Gemfile', '*.gemspec', 'Rakefile']
|
|
15
|
+
.select { |path| File.file?(path) }.sort
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.source_digest
|
|
20
|
+
digest = Digest::SHA256.new
|
|
21
|
+
source_files.each do |path|
|
|
22
|
+
digest.update(path).update("\0").update(File.binread(File.join(ROOT, path))).update("\0")
|
|
23
|
+
end
|
|
24
|
+
digest.update('haveapi/schema').update("\0").update(Snapshot.canonical(HaveAPI::Schema.source_identity)).update("\0")
|
|
25
|
+
digest.hexdigest
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(schema:, config: CONFIG)
|
|
29
|
+
unless config == CONFIG
|
|
30
|
+
raise Error, 'unsupported generator configuration'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@schema = Snapshot.validate(schema)
|
|
34
|
+
@ir = IR.build(schema)
|
|
35
|
+
@config = CONFIG
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def generate(output:, check: false)
|
|
39
|
+
output = File.expand_path(output)
|
|
40
|
+
validate_destination(output)
|
|
41
|
+
require_relative 'emitter'
|
|
42
|
+
files = Emitter.new(@ir, @config).files
|
|
43
|
+
validate_files(files)
|
|
44
|
+
manifest = {
|
|
45
|
+
'owner' => OWNER, 'format' => 1, 'generator_version' => VERSION,
|
|
46
|
+
'generator_source_sha256' => self.class.source_digest,
|
|
47
|
+
'schema_sha256' => Snapshot.sha256(@schema), 'api_version' => @schema.fetch('api_version'),
|
|
48
|
+
'protocol_version' => @schema.fetch('protocol_version'), 'profile' => @schema.fetch('profile'),
|
|
49
|
+
'config' => @config.transform_keys(&:to_s), 'coverage' => coverage,
|
|
50
|
+
'files' => files.sort.to_h.transform_values { |content| Digest::SHA256.hexdigest(content) }
|
|
51
|
+
}
|
|
52
|
+
candidate = files.merge(OWNER_FILE => Snapshot.canonical(manifest))
|
|
53
|
+
return true if equal_tree?(output, candidate)
|
|
54
|
+
raise Error, 'generated tree is stale; regenerate without --check' if check
|
|
55
|
+
|
|
56
|
+
install(output, candidate)
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def coverage
|
|
63
|
+
count_resources = lambda do |map|
|
|
64
|
+
map.values.sum { |resource| 1 + count_resources.call(resource.fetch('resources')) }
|
|
65
|
+
end
|
|
66
|
+
count_actions = lambda do |map|
|
|
67
|
+
map.values.sum { |resource| resource.fetch('actions').length + count_actions.call(resource.fetch('resources')) }
|
|
68
|
+
end
|
|
69
|
+
application = @schema.fetch('description').fetch('resources')
|
|
70
|
+
auth = @schema.fetch('description').fetch('authentication').values.map { |config| config.fetch('resources', {}) }
|
|
71
|
+
{
|
|
72
|
+
'application_resource_count' => count_resources.call(application),
|
|
73
|
+
'application_action_count' => @ir.fetch(:actions).length,
|
|
74
|
+
'authentication_resource_count' => auth.sum { |map| count_resources.call(map) },
|
|
75
|
+
'authentication_action_count' => auth.sum { |map| count_actions.call(map) },
|
|
76
|
+
'authentication_exclusion' => 'Authentication resources are retained and validated; the existing runtime owns their API-root routes.'
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def safe_relative?(path)
|
|
81
|
+
path.is_a?(String) && !path.empty? && !path.start_with?('/') && !path.include?('\\') &&
|
|
82
|
+
!path.match?(/[\x00-\x1f\x7f]/) && path.split('/').none? { |part| ['', '.', '..'].include?(part) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def validate_files(files)
|
|
86
|
+
raise Error, 'emitter must return a nonempty file map including index.ts' unless files.is_a?(Hash) && files.has_key?('index.ts')
|
|
87
|
+
|
|
88
|
+
files.each do |path, content|
|
|
89
|
+
unless safe_relative?(path) && path != OWNER_FILE && content.is_a?(String) &&
|
|
90
|
+
content.encoding == Encoding::UTF_8 && content.valid_encoding? && !content.include?("\r") && content.end_with?("\n")
|
|
91
|
+
raise Error, "unsafe generated file or non-UTF-8/LF content: #{path.inspect}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
fail_conflict = files.keys.any? { |other| other != path && path.start_with?("#{other}/") }
|
|
95
|
+
raise Error, "generated file/directory conflict: #{path}" if fail_conflict
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_destination(output)
|
|
100
|
+
unless File.basename(output) == 'generated' && File.basename(File.dirname(output)) == 'src'
|
|
101
|
+
raise Error, 'output must be the exclusively generated src/generated directory'
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
cursor = output
|
|
105
|
+
loop do
|
|
106
|
+
raise Error, 'generated destination contains a symlink' if File.symlink?(cursor)
|
|
107
|
+
break if cursor == File.dirname(cursor)
|
|
108
|
+
|
|
109
|
+
cursor = File.dirname(cursor)
|
|
110
|
+
end
|
|
111
|
+
return unless File.exist?(output)
|
|
112
|
+
raise Error, 'generated destination is not a directory' unless File.directory?(output)
|
|
113
|
+
|
|
114
|
+
files = tree_files(output)
|
|
115
|
+
return if files.empty? && Dir.empty?(output)
|
|
116
|
+
|
|
117
|
+
marker = File.join(output, OWNER_FILE)
|
|
118
|
+
raise Error, 'nonempty destination has no generator ownership manifest' unless File.file?(marker)
|
|
119
|
+
|
|
120
|
+
owner = JSON.parse(File.read(marker))
|
|
121
|
+
unless owner['owner'] == OWNER && owner['format'] == 1 && owner['files'].is_a?(Hash) &&
|
|
122
|
+
owner['files'].all? { |path, hash| safe_relative?(path) && path != OWNER_FILE && hash.is_a?(String) && hash.match?(/\A[0-9a-f]{64}\z/) }
|
|
123
|
+
raise Error, 'invalid generator ownership manifest'
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
unknown = files - owner.fetch('files').keys - [OWNER_FILE]
|
|
127
|
+
raise Error, "destination contains files not owned by generator: #{unknown.join(', ')}" unless unknown.empty?
|
|
128
|
+
rescue JSON::ParserError
|
|
129
|
+
raise Error, 'invalid generator ownership JSON'
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def tree_files(root)
|
|
133
|
+
return [] unless File.directory?(root)
|
|
134
|
+
|
|
135
|
+
Dir.glob('**/*', File::FNM_DOTMATCH, base: root).reject { |path| %w[. ..].include?(File.basename(path)) }.filter_map do |path|
|
|
136
|
+
full = File.join(root, path)
|
|
137
|
+
raise Error, "generated directory contains a symlink: #{path}" if File.symlink?(full)
|
|
138
|
+
next if File.directory?(full)
|
|
139
|
+
raise Error, "generated directory contains a nonregular file: #{path}" unless File.file?(full)
|
|
140
|
+
|
|
141
|
+
path
|
|
142
|
+
end.sort
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def equal_tree?(output, candidate)
|
|
146
|
+
tree_files(output) == candidate.keys.sort && candidate.all? do |path, content|
|
|
147
|
+
File.binread(File.join(output, path)) == content.b
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def install(output, candidate)
|
|
152
|
+
previous = tree_files(output)
|
|
153
|
+
FileUtils.mkdir_p(output)
|
|
154
|
+
# Each file replacement is atomic. The manifest is installed last so an
|
|
155
|
+
# interrupted regeneration is recognized as drift by the next --check.
|
|
156
|
+
candidate.reject { |path, _content| path == OWNER_FILE }.each { |path, content| write_file(output, path, content) }
|
|
157
|
+
(previous - candidate.keys).each { |path| File.unlink(File.join(output, path)) }
|
|
158
|
+
write_file(output, OWNER_FILE, candidate.fetch(OWNER_FILE))
|
|
159
|
+
Dir.glob('**/*', base: output).sort.reverse_each do |path|
|
|
160
|
+
full = File.join(output, path)
|
|
161
|
+
Dir.rmdir(full) if File.directory?(full) && Dir.empty?(full)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def write_file(output, path, content)
|
|
166
|
+
target = File.join(output, path)
|
|
167
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
168
|
+
Tempfile.create(['.haveapi-', '.tmp'], File.dirname(target)) do |temp|
|
|
169
|
+
temp.binmode
|
|
170
|
+
temp.write(content)
|
|
171
|
+
temp.close
|
|
172
|
+
File.rename(temp.path, target)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
module HaveAPI::TypeScriptClient
|
|
2
|
+
class IR
|
|
3
|
+
TYPES = %w[String Text Integer Float Boolean Datetime Resource Custom].freeze
|
|
4
|
+
LAYOUTS = %w[object object_list hash hash_list].freeze
|
|
5
|
+
METHODS = %w[GET DELETE POST PUT PATCH].freeze
|
|
6
|
+
|
|
7
|
+
def self.build(snapshot)
|
|
8
|
+
new(snapshot).build
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(snapshot)
|
|
12
|
+
@snapshot = Snapshot.validate(snapshot)
|
|
13
|
+
@exports = Naming::Scope.new(Naming::EXPORT_RESERVED)
|
|
14
|
+
@actions = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def build
|
|
18
|
+
description = @snapshot.fetch('description')
|
|
19
|
+
namespace = description.dig('meta', 'namespace')
|
|
20
|
+
fail_at('invalid metadata namespace', %w[meta namespace]) unless namespace.is_a?(String) && !namespace.empty?
|
|
21
|
+
fail_at('reserved metadata namespace', %w[meta namespace]) if Naming::RESERVED.include?(namespace)
|
|
22
|
+
|
|
23
|
+
resources = walk(description.fetch('resources'), [])
|
|
24
|
+
description.fetch('authentication').sort.each do |provider, config|
|
|
25
|
+
next unless config.has_key?('resources')
|
|
26
|
+
|
|
27
|
+
# Validate separately: these API-root routes belong to the runtime's
|
|
28
|
+
# authentication implementation, outside the version-root executor.
|
|
29
|
+
self.class.new(@snapshot).send(:walk, config['resources'], ['authentication', provider], authentication: true)
|
|
30
|
+
end
|
|
31
|
+
freeze_data({
|
|
32
|
+
api_version: @snapshot.fetch('api_version'), schema_sha256: Snapshot.sha256(@snapshot),
|
|
33
|
+
metadata_namespace: namespace, resources: resources,
|
|
34
|
+
actions: @actions.sort_by { |action| action[:schema_path] }
|
|
35
|
+
})
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def fail_at(message, path)
|
|
41
|
+
raise Error, "#{message} at #{path.join('/')}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def walk(map, parents, authentication: false)
|
|
45
|
+
fail_at('resources must be an object', parents) unless map.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
scope = Naming::Scope.new
|
|
48
|
+
map.sort.map do |name, desc|
|
|
49
|
+
path = parents + [name]
|
|
50
|
+
fail_at('resource must be an object', path) unless desc.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
accessor = authentication && parents.length == 2 ? Naming.identifier(path.join('__')) : Naming.identifier(name)
|
|
53
|
+
scope.add(accessor, path.join('/'))
|
|
54
|
+
export = Naming.export(path)
|
|
55
|
+
@exports.add("#{export}Resource", "#{path.join('/')} (Resource)")
|
|
56
|
+
children = walk(desc.fetch('resources'), path, authentication:)
|
|
57
|
+
member_scope = Naming::Scope.new
|
|
58
|
+
children.each { |child| member_scope.add(child[:accessor_name], child[:schema_path].join('/')) }
|
|
59
|
+
action_map = desc.fetch('actions')
|
|
60
|
+
fail_at('actions must be an object', path) unless action_map.is_a?(Hash)
|
|
61
|
+
|
|
62
|
+
actions = action_map.sort.map { |action_name, action| action_record(action_name, action, path, member_scope, authentication:) }
|
|
63
|
+
{ schema_path: path, export_name: export, accessor_name: accessor, children:, actions: }
|
|
64
|
+
end
|
|
65
|
+
rescue KeyError => e
|
|
66
|
+
fail_at("missing #{e.key}", parents)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def action_record(name, desc, parents, scope, authentication:)
|
|
70
|
+
path = parents + [name]
|
|
71
|
+
fail_at('action must be an object', path) unless desc.is_a?(Hash)
|
|
72
|
+
accessor = scope.add(Naming.identifier(name), path.join('/'))
|
|
73
|
+
export = Naming.export(path)
|
|
74
|
+
%w[Path Input Output GlobalInput GlobalOutput ObjectInput ObjectOutput Options].each do |suffix|
|
|
75
|
+
@exports.add("#{export}#{suffix}", "#{path.join('/')} (#{suffix})")
|
|
76
|
+
end
|
|
77
|
+
aliases = desc.fetch('aliases', [])
|
|
78
|
+
fail_at('aliases must be an array of strings', path) unless aliases.is_a?(Array) && aliases.all?(String)
|
|
79
|
+
aliases.each { |item| scope.add(Naming.identifier(item), (path + ['alias', item]).join('/')) }
|
|
80
|
+
%w[auth blocking].each do |key|
|
|
81
|
+
next unless desc.has_key?(key)
|
|
82
|
+
|
|
83
|
+
fail_at("#{key} must be boolean or null", path) unless [true, false, nil].include?(desc[key])
|
|
84
|
+
end
|
|
85
|
+
route = route(desc['path'], path, authentication:)
|
|
86
|
+
placeholders = route.scan(/\{([^}]+)\}/).flatten.uniq
|
|
87
|
+
placeholder_scope = Naming::Scope.new
|
|
88
|
+
placeholders.each { |item| placeholder_scope.add(Naming.identifier(item), (path + ['path', item]).join('/')) }
|
|
89
|
+
action = {
|
|
90
|
+
schema_path: path, export_name: export, accessor_name: accessor, aliases:, method: desc['method'],
|
|
91
|
+
path_template: route, placeholders:, auth: desc.fetch('auth', true) != false, blocking: desc['blocking'] == true,
|
|
92
|
+
input: io(desc['input'], path + ['input'], input: true), output: io(desc['output'], path + ['output'])
|
|
93
|
+
}
|
|
94
|
+
metadata = desc['meta'] || {}
|
|
95
|
+
fail_at('metadata must be an object', path + ['meta']) unless metadata.is_a?(Hash)
|
|
96
|
+
unknown = metadata.keys - %w[global object]
|
|
97
|
+
fail_at("unsupported metadata roles #{unknown.join(', ')}", path + ['meta']) unless unknown.empty?
|
|
98
|
+
%w[global object].each do |role|
|
|
99
|
+
group = metadata[role] || {}
|
|
100
|
+
fail_at('metadata role must be an object', path + ['meta', role]) unless group.is_a?(Hash)
|
|
101
|
+
%w[input output].each do |direction|
|
|
102
|
+
action[:"#{role}_#{direction}"] = io(group[direction], path + ['meta', role, direction], input: direction == 'input', metadata: true)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
if %w[GET OPTIONS].include?(desc['method']) && action[:object_input]
|
|
106
|
+
fail_at('unsupported query object-input metadata', path + %w[meta object input])
|
|
107
|
+
end
|
|
108
|
+
fail_at('unsupported HTTP method', path) unless METHODS.include?(desc['method'])
|
|
109
|
+
namespace = @snapshot.fetch('description').fetch('meta').fetch('namespace')
|
|
110
|
+
if action[:object_input] && action[:input]&.fetch(:parameters)&.any? { |field| [field[:wire_name], field[:emitted_name]].include?(namespace) }
|
|
111
|
+
fail_at('object metadata namespace collides with input field', path + ['input', namespace])
|
|
112
|
+
end
|
|
113
|
+
if action[:object_output] && action[:output]&.fetch(:parameters)&.any? { |field| field[:wire_name] == namespace }
|
|
114
|
+
fail_at('object metadata namespace collides with output field', path + ['output', namespace])
|
|
115
|
+
end
|
|
116
|
+
@actions << action
|
|
117
|
+
action
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def io(desc, path, input: false, metadata: false)
|
|
121
|
+
return nil if desc.nil? || desc == { 'parameters' => {} }
|
|
122
|
+
|
|
123
|
+
fail_at('IO must be an object', path) unless desc.is_a?(Hash)
|
|
124
|
+
fail_at('unsupported Custom IO format', path) if desc.has_key?('format')
|
|
125
|
+
fail_at('unsupported layout', path) unless LAYOUTS.include?(desc['layout'])
|
|
126
|
+
if input && desc['layout'].end_with?('_list')
|
|
127
|
+
fail_at('unsupported input list', path)
|
|
128
|
+
end
|
|
129
|
+
namespace = desc['namespace']
|
|
130
|
+
unless namespace == false || (namespace.is_a?(String) && !namespace.empty?)
|
|
131
|
+
fail_at('unsupported namespace', path)
|
|
132
|
+
end
|
|
133
|
+
fail_at('unsupported output namespace=false', path) if !input && !metadata && namespace == false
|
|
134
|
+
parameters = desc['parameters']
|
|
135
|
+
fail_at('parameters must be an object', path) unless parameters.is_a?(Hash)
|
|
136
|
+
scope = Naming::Scope.new
|
|
137
|
+
fields = parameters.sort.map { |name, parameter| parameter_record(name, parameter, path, scope) }
|
|
138
|
+
{ layout: desc['layout'], namespace:, parameters: fields }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def parameter_record(name, desc, parents, scope)
|
|
142
|
+
path = parents + [name]
|
|
143
|
+
fail_at('parameter must be an object', path) unless desc.is_a?(Hash)
|
|
144
|
+
fail_at("unsupported parameter type #{desc['type'].inspect}", path) unless TYPES.include?(desc['type'])
|
|
145
|
+
%w[required nullable protected].each do |key|
|
|
146
|
+
next unless desc.has_key?(key)
|
|
147
|
+
|
|
148
|
+
fail_at("#{key} must be boolean or null", path) unless [true, false, nil].include?(desc[key])
|
|
149
|
+
end
|
|
150
|
+
validators = desc.fetch('validators', {})
|
|
151
|
+
fail_at('validators must be an object', path) unless validators.is_a?(Hash)
|
|
152
|
+
association = nil
|
|
153
|
+
if desc['type'] == 'Resource'
|
|
154
|
+
resource = desc['resource']
|
|
155
|
+
unless resource.is_a?(Array) && !resource.empty? && resource.all? { |item| item.is_a?(String) && !item.empty? } &&
|
|
156
|
+
%w[value_id value_label].all? { |key| desc[key].is_a?(String) && !desc[key].empty? }
|
|
157
|
+
fail_at('invalid resource association', path)
|
|
158
|
+
end
|
|
159
|
+
association = { resource_path: resource, value_id: desc['value_id'], value_label: desc['value_label'] }
|
|
160
|
+
end
|
|
161
|
+
{
|
|
162
|
+
wire_name: name, emitted_name: scope.add(Naming.identifier(name), path.join('/')), kind: desc['type'],
|
|
163
|
+
required: desc['required'] == true, nullable: desc['nullable'] == true, protected: desc['protected'] == true,
|
|
164
|
+
association:, validators:
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def route(path, schema_path, authentication:)
|
|
169
|
+
fail_at('unsafe action path', schema_path) unless path.is_a?(String) && Snapshot.safe_path?(path)
|
|
170
|
+
mount = @snapshot.fetch('mount_path')
|
|
171
|
+
fail_at('action path escapes capture mount', schema_path) unless path.start_with?(mount)
|
|
172
|
+
relative = path.delete_prefix(mount)
|
|
173
|
+
version = "v#{@snapshot.fetch('api_version')}"
|
|
174
|
+
segments = relative.split('/', -1)
|
|
175
|
+
if segments.first == version
|
|
176
|
+
segments.shift
|
|
177
|
+
elsif !authentication
|
|
178
|
+
fail_at('action path differs from selected API version', schema_path)
|
|
179
|
+
elsif segments.first&.match?(/\Av\d+(?:\.\d+)*\z/)
|
|
180
|
+
fail_at('authentication action selects another API version', schema_path)
|
|
181
|
+
end
|
|
182
|
+
relative = segments.join('/')
|
|
183
|
+
unless !relative.empty? && relative.split('/').all? { |segment| segment.empty? || segment.match?(/\A(?:[a-zA-Z0-9_.%~-]+|\{[a-zA-Z0-9_-]+\})\z/) }
|
|
184
|
+
fail_at('unsupported action path template', schema_path)
|
|
185
|
+
end
|
|
186
|
+
"/#{relative}"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def freeze_data(value)
|
|
190
|
+
case value
|
|
191
|
+
when Hash
|
|
192
|
+
value.each do |key, item|
|
|
193
|
+
freeze_data(key)
|
|
194
|
+
freeze_data(item)
|
|
195
|
+
end
|
|
196
|
+
when Array
|
|
197
|
+
value.each { |item| freeze_data(item) }
|
|
198
|
+
end
|
|
199
|
+
value.freeze
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module HaveAPI::TypeScriptClient
|
|
2
|
+
module Naming
|
|
3
|
+
RESERVED = %w[then constructor prototype __proto__].freeze
|
|
4
|
+
EXPORT_RESERVED = %w[createClient RequestExecutor ClientOptions JsonValue API_VERSION SCHEMA_SHA256 RequestOptions executeAction].freeze
|
|
5
|
+
|
|
6
|
+
def self.identifier(name)
|
|
7
|
+
raise Error, 'schema name must be a nonempty string' unless name.is_a?(String) && !name.empty?
|
|
8
|
+
|
|
9
|
+
value = name.gsub(/[^a-zA-Z0-9_$]/, '_')
|
|
10
|
+
value = "_#{value}" unless value.match?(/\A[a-zA-Z_$]/)
|
|
11
|
+
value
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.export(path)
|
|
15
|
+
path.map do |name|
|
|
16
|
+
parts = name.split(/[^a-zA-Z0-9]+/).reject(&:empty?)
|
|
17
|
+
value = parts.map { |part| part[0].upcase + part[1..] }.join
|
|
18
|
+
value = "_#{value}" unless value.match?(/\A[a-zA-Z_$]/)
|
|
19
|
+
value
|
|
20
|
+
end.join('__')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class Scope
|
|
24
|
+
def initialize(reserved = RESERVED)
|
|
25
|
+
@names = reserved.to_h { |name| [name, "reserved helper #{name}"] }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def add(name, path)
|
|
29
|
+
if @names.has_key?(name)
|
|
30
|
+
raise Error, "name collision for #{name}: #{@names.fetch(name)} and #{path}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@names[name] = path
|
|
34
|
+
name
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
begin
|
|
2
|
+
require 'haveapi/schema'
|
|
3
|
+
rescue LoadError
|
|
4
|
+
raise HaveAPI::TypeScriptClient::Error,
|
|
5
|
+
'shared haveapi-client schema API is unavailable; prepare the component bundle from this source release'
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
module HaveAPI::TypeScriptClient
|
|
9
|
+
# Keep the generator API/error boundary while sharing one portable format.
|
|
10
|
+
module Snapshot
|
|
11
|
+
unless defined?(HaveAPI::Schema::Snapshot) && HaveAPI::Schema.respond_to?(:source_identity)
|
|
12
|
+
raise Error, 'loaded haveapi-client does not provide the required shared schema API'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
KEYS = HaveAPI::Schema::Snapshot::KEYS
|
|
16
|
+
PROSE = HaveAPI::Schema::Snapshot::PROSE
|
|
17
|
+
|
|
18
|
+
%i[canonical canonical_value sha256 load validate sanitize from_description from_envelope version! endpoint! safe_path? safe_segments? child_context sanitization_context machine_endpoint! validate_machine_endpoint validate_machine_endpoints].each do |name|
|
|
19
|
+
define_singleton_method(name) do |*args, **kwargs, &block|
|
|
20
|
+
HaveAPI::Schema::Snapshot.public_send(name, *args, **kwargs, &block)
|
|
21
|
+
rescue HaveAPI::Schema::Error => e
|
|
22
|
+
raise Error, e.message
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.fetch(**)
|
|
27
|
+
HaveAPI::Schema::Capture.fetch(**)
|
|
28
|
+
rescue HaveAPI::Schema::Error => e
|
|
29
|
+
raise Error, e.message
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
require_relative 'typescript_client/version'
|
|
2
|
+
|
|
3
|
+
module HaveAPI
|
|
4
|
+
module TypeScriptClient
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
end
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require_relative 'typescript_client/snapshot'
|
|
10
|
+
require_relative 'typescript_client/naming'
|
|
11
|
+
require_relative 'typescript_client/ir'
|
|
12
|
+
require_relative 'typescript_client/generator'
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Generated by HaveAPI. Regenerate from the recorded schema; do not edit.
|
|
2
|
+
import type {RequestSpec, RequestResult} from <%= literal(@runtime_import) %>;
|
|
3
|
+
import {executeAction} from <%= literal(relative_import('runtime.ts')) %>;
|
|
4
|
+
import type {JsonValue, RequestExecutor} from <%= literal(relative_import('runtime.ts')) %>;
|
|
5
|
+
|
|
6
|
+
<%= action_declarations(@record) %>
|
|
7
|
+
|
|
8
|
+
const descriptor = <%= literal(action_definition(@record)) %> as const;
|
|
9
|
+
|
|
10
|
+
/** Internal operation binding; public callers use createClient. */
|
|
11
|
+
export function createAction(execute: RequestExecutor): (options<%= '?' unless options_required?(@record) %>: <%= action_type(@record, 'Options') %>) => Promise<RequestResult<<%= action_type(@record, 'Output') %>>> {
|
|
12
|
+
return (options: <%= action_type(@record, 'Options') %><%= ' = {}' unless options_required?(@record) %>) => executeAction<<%= action_type(@record, 'Output') %>>(execute, descriptor, options);
|
|
13
|
+
}
|
data/template/api.ts.erb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Generated by HaveAPI. Regenerate from the recorded schema; do not edit.
|
|
2
|
+
import type {ClientOptions} from './runtime.js';
|
|
3
|
+
<% @ir.fetch(:resources).each_with_index do |resource, index| -%>
|
|
4
|
+
import {createResource as resource<%= index %>} from <%= literal(relative_import(@resource_paths.fetch(resource.fetch(:schema_path)))) %>;
|
|
5
|
+
import type {<%= resource.fetch(:export_name) %>Resource} from <%= literal(relative_import(@resource_paths.fetch(resource.fetch(:schema_path)))) %>;
|
|
6
|
+
<% end -%>
|
|
7
|
+
export type * from './types.js';
|
|
8
|
+
|
|
9
|
+
export const API_VERSION = <%= literal(@ir.fetch(:api_version)) %>;
|
|
10
|
+
export const SCHEMA_SHA256 = <%= literal(@ir.fetch(:schema_sha256)) %>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Bind to an executor already targeting the selected API version, for example
|
|
14
|
+
* spec => asyncClient.request(spec) with base https://host/mount/vVERSION.
|
|
15
|
+
* Generated output types do not validate response application fields at runtime.
|
|
16
|
+
* Authentication and completion remain with the consumer's HaveAPI runtime.
|
|
17
|
+
*/
|
|
18
|
+
export function createClient({execute}: ClientOptions): {
|
|
19
|
+
<% @ir.fetch(:resources).each do |resource| -%>
|
|
20
|
+
readonly <%= literal(resource.fetch(:accessor_name)) %>: <%= resource.fetch(:export_name) %>Resource;
|
|
21
|
+
<% end -%>
|
|
22
|
+
} {
|
|
23
|
+
return {
|
|
24
|
+
<% @ir.fetch(:resources).each_with_index do |resource, index| -%>
|
|
25
|
+
<%= literal(resource.fetch(:accessor_name)) %>: resource<%= index %>(execute),
|
|
26
|
+
<% end -%>
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Generated by HaveAPI. Regenerate from the recorded schema; do not edit.
|
|
2
|
+
import type {RequestResult} from <%= literal(@runtime_import) %>;
|
|
3
|
+
import type {RequestExecutor} from <%= literal(relative_import('runtime.ts')) %>;
|
|
4
|
+
<% @record.fetch(:children).each_with_index do |child, index| -%>
|
|
5
|
+
import {createResource as child<%= index %>} from <%= literal(relative_import(@resource_paths.fetch(child.fetch(:schema_path)))) %>;
|
|
6
|
+
import type {<%= child.fetch(:export_name) %>Resource} from <%= literal(relative_import(@resource_paths.fetch(child.fetch(:schema_path)))) %>;
|
|
7
|
+
<% end -%>
|
|
8
|
+
<% @record.fetch(:actions).each_with_index do |action, index| -%>
|
|
9
|
+
import {createAction as bind<%= index %>} from <%= literal(relative_import(@action_paths.fetch(action.fetch(:schema_path)))) %>;
|
|
10
|
+
import type {<%= action_type(action, 'Options') %>, <%= action_type(action, 'Output') %>} from <%= literal(relative_import(@action_paths.fetch(action.fetch(:schema_path)))) %>;
|
|
11
|
+
<% end -%>
|
|
12
|
+
|
|
13
|
+
<%= resource_declaration(@record) %>
|
|
14
|
+
|
|
15
|
+
/** Internal resource composition; public callers use createClient. */
|
|
16
|
+
export function createResource(execute: RequestExecutor): <%= @record.fetch(:export_name) %>Resource {
|
|
17
|
+
<% @record.fetch(:actions).each_with_index do |_action, index| -%>
|
|
18
|
+
const action<%= index %> = bind<%= index %>(execute);
|
|
19
|
+
<% end -%>
|
|
20
|
+
return {
|
|
21
|
+
<% @record.fetch(:children).each_with_index do |child, index| -%>
|
|
22
|
+
<%= literal(child.fetch(:accessor_name)) %>: child<%= index %>(execute),
|
|
23
|
+
<% end -%>
|
|
24
|
+
<% @record.fetch(:actions).each_with_index do |action, index| -%>
|
|
25
|
+
<% action_names(action).each do |name| -%>
|
|
26
|
+
<%= literal(name) %>: action<%= index %>,
|
|
27
|
+
<% end -%>
|
|
28
|
+
<% end -%>
|
|
29
|
+
};
|
|
30
|
+
}
|