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.
@@ -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,5 @@
1
+ module HaveAPI
2
+ module TypeScriptClient
3
+ VERSION = '0.31.0'.freeze
4
+ end
5
+ 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
+ }
@@ -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,3 @@
1
+ // Generated by HaveAPI. Regenerate from the recorded schema; do not edit.
2
+ export * from './api.js';
3
+ export type {ClientOptions, JsonValue, RequestExecutor} from './runtime.js';
@@ -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
+ }