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,316 @@
|
|
|
1
|
+
require_relative '../typescript_client'
|
|
2
|
+
require 'uri'
|
|
3
|
+
|
|
4
|
+
module HaveAPI::TypeScriptClient
|
|
5
|
+
# Names belong to the complete SDK; request-local help only limits their use.
|
|
6
|
+
class Documentation
|
|
7
|
+
class Unsupported < StandardError; end
|
|
8
|
+
|
|
9
|
+
def self.from_description(description:, api_version:, protocol_version:, mount_path:, package_name:, package_version: nil)
|
|
10
|
+
snapshot = Snapshot.from_description(
|
|
11
|
+
description: JSON.parse(JSON.generate(description)), api_version:, protocol_version:, mount_path:,
|
|
12
|
+
source_url: "https://haveapi.invalid#{mount_path}v#{api_version}/", profile: 'server-documentation'
|
|
13
|
+
)
|
|
14
|
+
from_snapshot(snapshot:, package_name:, package_version:)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.from_snapshot(snapshot:, package_name:, package_version: nil)
|
|
18
|
+
new(snapshot:, package_name:, package_version:)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(snapshot:, package_name:, package_version: nil)
|
|
22
|
+
name_pattern = %r{\A(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*\z}
|
|
23
|
+
semver = /\A(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z/
|
|
24
|
+
unless package_name.is_a?(String) && package_name.length <= 214 && package_name.match?(name_pattern) &&
|
|
25
|
+
!%w[node_modules favicon.ico].include?(package_name) &&
|
|
26
|
+
(package_version.nil? || (package_version.is_a?(String) && package_version.match?(semver) &&
|
|
27
|
+
package_version.split('+', 2).first.split('-', 2).last.split('.').none? { |part| part.match?(/\A0\d+\z/) }))
|
|
28
|
+
raise Error, 'Invalid TypeScript documentation package identity'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
@package_name = package_name.dup.freeze
|
|
32
|
+
@package_version = package_version&.dup&.freeze
|
|
33
|
+
@snapshot = Snapshot.validate(JSON.parse(Snapshot.canonical(snapshot)))
|
|
34
|
+
@ir = IR.build(@snapshot)
|
|
35
|
+
@index = {}
|
|
36
|
+
@accessors = {}
|
|
37
|
+
index_resources(@ir.fetch(:resources), [])
|
|
38
|
+
freeze_data(@snapshot)
|
|
39
|
+
freeze_data(@index)
|
|
40
|
+
freeze_data(@accessors)
|
|
41
|
+
freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def resolve(resource_path:, action_name:)
|
|
45
|
+
@index[[resource_path.map(&:to_s), action_name.to_s]]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def render(kind:, context:, mode: :async, auth_method: nil, auth_description: nil)
|
|
49
|
+
unsupported! unless mode == :async && context.version.to_s == @snapshot.fetch('api_version')
|
|
50
|
+
source = case kind
|
|
51
|
+
when :init then program(context, ['void api;'])
|
|
52
|
+
when :auth then program(context, ['void api;'], authentication(context, auth_method, auth_description))
|
|
53
|
+
when :example then example(context)
|
|
54
|
+
else unsupported!
|
|
55
|
+
end
|
|
56
|
+
{ status: :code, code: source.freeze }.freeze
|
|
57
|
+
rescue Unsupported, JSON::GeneratorError, EncodingError
|
|
58
|
+
# Never name fields or actions from the private SDK in public diagnostics.
|
|
59
|
+
{ status: :limitation, reason: 'This sample is unavailable or incompatible with the configured TypeScript SDK.'.freeze }.freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def unsupported!
|
|
65
|
+
raise Unsupported
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def freeze_data(value)
|
|
69
|
+
case value
|
|
70
|
+
when Hash then value.each do |key, item|
|
|
71
|
+
freeze_data(key)
|
|
72
|
+
freeze_data(item)
|
|
73
|
+
end
|
|
74
|
+
when Array then value.each { |item| freeze_data(item) }
|
|
75
|
+
end
|
|
76
|
+
value.freeze
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def normalize(value)
|
|
80
|
+
case value
|
|
81
|
+
when Hash then value.to_h { |key, item| [key.to_s, normalize(item)] }
|
|
82
|
+
when Array then value.map { |item| normalize(item) }
|
|
83
|
+
when Symbol then value.to_s
|
|
84
|
+
else value
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def index_resources(resources, parents)
|
|
89
|
+
resources.each do |resource|
|
|
90
|
+
accessors = (parents + [resource.fetch(:accessor_name)]).freeze
|
|
91
|
+
resource.fetch(:actions).each do |action|
|
|
92
|
+
names = [action.fetch(:schema_path).last] + action.fetch(:aliases)
|
|
93
|
+
names.each { |name| @index[[action.fetch(:schema_path)[0...-1], name].freeze] = action }
|
|
94
|
+
@accessors[action.fetch(:schema_path)] = accessors
|
|
95
|
+
end
|
|
96
|
+
index_resources(resource.fetch(:children), accessors)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def literal(value)
|
|
101
|
+
return "{#{value.map { |key, item| "#{key == '__proto__' ? '["__proto__"]' : literal(key)}:#{literal(item)}" }.join(',')}}" if value.is_a?(Hash)
|
|
102
|
+
return "[#{value.map { |item| literal(item) }.join(',')}]" if value.is_a?(Array)
|
|
103
|
+
|
|
104
|
+
JSON.generate(value).gsub("\u2028", '\\u2028').gsub("\u2029", '\\u2029')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def program(context, body, auth = [])
|
|
108
|
+
version_url = context.version_url.to_s.sub(%r{/+\z}, '')
|
|
109
|
+
uri = URI.parse(version_url)
|
|
110
|
+
unsupported! unless uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo && !uri.query && !uri.fragment &&
|
|
111
|
+
uri.path.end_with?("/v#{@snapshot.fetch('api_version')}") && Snapshot.safe_path?(uri.path)
|
|
112
|
+
dependencies = "// Configured SDK: #{@package_name}#{"@#{@package_version}" if @package_version}; runtime: haveapi-client@0.30.2."
|
|
113
|
+
lines = [dependencies, "import {AsyncClient, AsyncError} from 'haveapi-client';", "import {createClient} from #{literal(@package_name)};"]
|
|
114
|
+
unless auth.empty?
|
|
115
|
+
lines += ["import {env} from 'node:process';", '', 'function credential(name: string): string {', ' const value = env[name];',
|
|
116
|
+
' if (!value) throw new Error(`Missing environment variable ${name}`);', ' return value;', '}']
|
|
117
|
+
end
|
|
118
|
+
lines += ['', 'async function main(): Promise<void> {', " const runtime = new AsyncClient(#{literal(version_url)});"]
|
|
119
|
+
lines.concat(auth.map { |line| " #{line}" })
|
|
120
|
+
lines << ' const api = createClient({execute: spec => runtime.request(spec)});'
|
|
121
|
+
lines.concat(body.map { |line| " #{line}" })
|
|
122
|
+
lines.push('}', '', 'main().catch(error => { throw error; });', '').join("\n")
|
|
123
|
+
rescue URI::InvalidURIError
|
|
124
|
+
unsupported!
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def authentication(context, method, description = nil)
|
|
128
|
+
providers = normalize(context.description)['authentication'] || {}
|
|
129
|
+
saved = @snapshot.fetch('description').fetch('authentication')
|
|
130
|
+
live = providers[method.to_s]
|
|
131
|
+
stored = saved[method.to_s]
|
|
132
|
+
unsupported! unless live.is_a?(Hash) && stored.is_a?(Hash)
|
|
133
|
+
unsupported! if description && normalize(description) != live
|
|
134
|
+
case method.to_s
|
|
135
|
+
when 'basic'
|
|
136
|
+
['await runtime.authenticate(\'basic\', {user: credential("HAVEAPI_USER"), password: credential("HAVEAPI_PASSWORD")});']
|
|
137
|
+
when 'token'
|
|
138
|
+
header = live['http_header']
|
|
139
|
+
unsupported! unless header.is_a?(String) && header.match?(/\A[!#$%&'*+.^_`|~0-9A-Za-z-]+\z/) && stored['http_header'] == header
|
|
140
|
+
['await runtime.authenticate(\'token\', {token: credential("HAVEAPI_TOKEN")});']
|
|
141
|
+
else unsupported!
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def action_authentication(action, context)
|
|
146
|
+
return [] unless action.fetch(:auth)
|
|
147
|
+
|
|
148
|
+
live = normalize(context.description).fetch('authentication', {})
|
|
149
|
+
%w[basic token].each do |method|
|
|
150
|
+
next unless live.has_key?(method) && @snapshot.fetch('description').fetch('authentication').has_key?(method)
|
|
151
|
+
|
|
152
|
+
return authentication(context, method)
|
|
153
|
+
end
|
|
154
|
+
unsupported!
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def authorized_action(context)
|
|
158
|
+
description = normalize(context.description)
|
|
159
|
+
resources = description['resources']
|
|
160
|
+
resource = nil
|
|
161
|
+
context.resource_path.each do |name|
|
|
162
|
+
resource = resources.is_a?(Hash) && resources[name.to_s]
|
|
163
|
+
return nil unless resource.is_a?(Hash)
|
|
164
|
+
|
|
165
|
+
resources = resource['resources']
|
|
166
|
+
end
|
|
167
|
+
actions = resource && resource['actions']
|
|
168
|
+
return nil unless actions.is_a?(Hash)
|
|
169
|
+
return actions[context.action_name.to_s] if actions.has_key?(context.action_name.to_s)
|
|
170
|
+
|
|
171
|
+
aliases = actions.values.select { |action| Array(action['aliases']).include?(context.action_name.to_s) }
|
|
172
|
+
aliases.one? ? aliases.first : nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def saved_action(action)
|
|
176
|
+
current = @snapshot.fetch('description')
|
|
177
|
+
action.fetch(:schema_path)[0...-1].each { |name| current = current.fetch('resources').fetch(name) }
|
|
178
|
+
current.fetch('actions').fetch(action.fetch(:schema_path).last)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def compatible_action!(action, saved, live, context)
|
|
182
|
+
unsupported! unless live.is_a?(Hash)
|
|
183
|
+
expected_path = URI.parse(context.version_url.to_s).path.sub(%r{/+\z}, '') + action.fetch(:path_template)
|
|
184
|
+
unsupported! unless live['path'] == expected_path && live['method'] == action.fetch(:method) &&
|
|
185
|
+
(live['auth'] != false) == action.fetch(:auth) && (live['blocking'] == true) == action.fetch(:blocking)
|
|
186
|
+
unsupported! unless normalize(context.description).dig('meta', 'namespace') == @ir.fetch(:metadata_namespace)
|
|
187
|
+
%w[input output].each do |direction|
|
|
188
|
+
left = saved[direction]
|
|
189
|
+
right = live[direction]
|
|
190
|
+
next if empty_io?(left) && empty_io?(right)
|
|
191
|
+
|
|
192
|
+
unsupported! unless left.is_a?(Hash) && right.is_a?(Hash) && %w[layout namespace format].all? { |key| left[key] == right[key] }
|
|
193
|
+
end
|
|
194
|
+
%w[global object].each do |role|
|
|
195
|
+
[saved, live].each do |description|
|
|
196
|
+
parameters = description.dig('meta', role, 'input', 'parameters') || {}
|
|
197
|
+
unsupported! if parameters.any? { |_name, parameter| parameter['required'] == true }
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
rescue URI::InvalidURIError
|
|
201
|
+
unsupported!
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def empty_io?(value)
|
|
205
|
+
value.nil? || value == { 'parameters' => {} }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def compatible_parameter?(saved, live)
|
|
209
|
+
saved.is_a?(Hash) && live.is_a?(Hash) &&
|
|
210
|
+
%w[type resource value_id value_label].all? { |key| saved[key] == live[key] } &&
|
|
211
|
+
%w[required nullable].all? { |key| (saved[key] == true) == (live[key] == true) }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def valid_json?(value)
|
|
215
|
+
case value
|
|
216
|
+
when Hash then value.all? { |key, item| key.is_a?(String) && valid_json?(item) }
|
|
217
|
+
when Array then value.all? { |item| valid_json?(item) }
|
|
218
|
+
when Float then value.finite?
|
|
219
|
+
when Integer then value.abs <= 9_007_199_254_740_991
|
|
220
|
+
when String, TrueClass, FalseClass, NilClass then true
|
|
221
|
+
else false
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def valid_value?(parameter, value, output: false, query: false)
|
|
226
|
+
return output || parameter['nullable'] == true if value.nil?
|
|
227
|
+
|
|
228
|
+
case parameter['type']
|
|
229
|
+
when 'String', 'Text', 'Datetime' then value.is_a?(String)
|
|
230
|
+
when 'Boolean' then [true, false].include?(value)
|
|
231
|
+
when 'Integer' then value.is_a?(Integer) && value.abs <= 9_007_199_254_740_991
|
|
232
|
+
when 'Resource'
|
|
233
|
+
output ? value.is_a?(Hash) && valid_json?(value) : value.is_a?(Integer) && value.abs <= 9_007_199_254_740_991
|
|
234
|
+
when 'Float' then value.is_a?(Numeric) && value.finite?
|
|
235
|
+
when 'Custom' then valid_json?(value) && (!query || (!value.is_a?(Hash) && !value.is_a?(Array)))
|
|
236
|
+
else false
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def validate_object!(saved_io, live_io, object, input:, query: false)
|
|
241
|
+
unsupported! unless object.is_a?(Hash)
|
|
242
|
+
saved = (saved_io && saved_io.fetch('parameters', {})) || {}
|
|
243
|
+
live = (live_io && live_io.fetch('parameters', {})) || {}
|
|
244
|
+
object.each do |name, value|
|
|
245
|
+
unsupported! unless compatible_parameter?(saved[name], live[name]) && valid_value?(saved[name], value, output: !input, query:)
|
|
246
|
+
end
|
|
247
|
+
return unless input
|
|
248
|
+
|
|
249
|
+
[saved, live].each do |parameters|
|
|
250
|
+
unsupported! if parameters.any? { |name, parameter| parameter['required'] == true && !object.has_key?(name) }
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def example(context)
|
|
255
|
+
action = resolve(resource_path: context.resource_path, action_name: context.action_name)
|
|
256
|
+
live = authorized_action(context)
|
|
257
|
+
unsupported! unless action && live
|
|
258
|
+
saved = saved_action(action)
|
|
259
|
+
compatible_action!(action, saved, live, context)
|
|
260
|
+
current = normalize(context.action)
|
|
261
|
+
compatible_action!(action, saved, current, context)
|
|
262
|
+
sample = normalize(context.sample)
|
|
263
|
+
unsupported! unless sample.is_a?(Hash) && [true, false].include?(sample['status'])
|
|
264
|
+
paths = sample['path_params'] || []
|
|
265
|
+
unsupported! unless paths.is_a?(Array) && paths.length == action.fetch(:placeholders).length && paths.all? do |value|
|
|
266
|
+
(value.is_a?(Integer) && value.abs <= 9_007_199_254_740_991) || (value.is_a?(String) && !value.empty? && !%w[. ..].include?(value))
|
|
267
|
+
end
|
|
268
|
+
input = sample['request'].nil? ? {} : sample['request']
|
|
269
|
+
validate_object!(saved['input'], live['input'], input, input: true, query: %w[GET OPTIONS].include?(action.fetch(:method)))
|
|
270
|
+
validate_object!(saved['input'], current['input'], input, input: true, query: %w[GET OPTIONS].include?(action.fetch(:method)))
|
|
271
|
+
options = {}
|
|
272
|
+
unless paths.empty?
|
|
273
|
+
options['path'] = action.fetch(:placeholders).each_with_index.to_h { |name, index| [Naming.identifier(name), paths[index]] }
|
|
274
|
+
end
|
|
275
|
+
unless input.empty?
|
|
276
|
+
fields = action.fetch(:input).fetch(:parameters)
|
|
277
|
+
options['params'] = input.to_h { |name, value| [fields.find { |field| field[:wire_name] == name }.fetch(:emitted_name), value] }
|
|
278
|
+
end
|
|
279
|
+
response = sample['response']
|
|
280
|
+
unless response.nil?
|
|
281
|
+
values = action[:output] && action[:output][:layout].end_with?('_list') ? response : [response]
|
|
282
|
+
unsupported! unless values.is_a?(Array)
|
|
283
|
+
values.each do |value|
|
|
284
|
+
validate_object!(saved['output'], live['output'], value, input: false)
|
|
285
|
+
validate_object!(saved['output'], current['output'], value, input: false)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
members = (@accessors.fetch(action.fetch(:schema_path)) + [action.fetch(:accessor_name)]).map { |name| "[#{literal(name)}]" }.join
|
|
289
|
+
call = "api#{members}"
|
|
290
|
+
call += "(#{literal(options) unless options.empty?})"
|
|
291
|
+
if sample['status'] == false || (sample['http_status'].is_a?(Integer) && sample['http_status'] >= 400)
|
|
292
|
+
body = ['try {', " await #{call};", ' throw new Error("Expected the documented request to fail");',
|
|
293
|
+
'} catch (error) {', ' if (!(error instanceof AsyncError)) throw error;',
|
|
294
|
+
' void [error.kind, error.httpStatus, error.errors, error.envelope, error.response];', '}']
|
|
295
|
+
else
|
|
296
|
+
body = ["const result = await #{call};", 'void [result.data, result.meta, result.envelope, result.httpStatus];']
|
|
297
|
+
body.concat(output_lines(action, response))
|
|
298
|
+
end
|
|
299
|
+
body << '// Request success does not establish completion of a server-side operation.' if action.fetch(:blocking)
|
|
300
|
+
program(context, body, action_authentication(action, context))
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def output_lines(action, response)
|
|
304
|
+
return [] unless response && action[:output]
|
|
305
|
+
|
|
306
|
+
list = action[:output][:layout].end_with?('_list')
|
|
307
|
+
values = list ? response : [response]
|
|
308
|
+
names = values.flat_map(&:keys).uniq
|
|
309
|
+
body = names.flat_map do |name|
|
|
310
|
+
expression = "item[#{literal(name)}]"
|
|
311
|
+
["const value#{names.index(name)}: unknown = #{expression};", "if (value#{names.index(name)} !== null && value#{names.index(name)} !== undefined) void value#{names.index(name)};"]
|
|
312
|
+
end
|
|
313
|
+
list ? ['for (const item of result.data) {'] + body.map { |line| " #{line}" } + ['}'] : ['const item = result.data;'] + body
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
require 'erb'
|
|
2
|
+
require 'json'
|
|
3
|
+
|
|
4
|
+
module HaveAPI::TypeScriptClient
|
|
5
|
+
# Templates are source inputs loaded once, outside the pure emission seam.
|
|
6
|
+
class Emitter
|
|
7
|
+
TEMPLATE_ROOT = File.expand_path('../../../template', __dir__).freeze
|
|
8
|
+
TEMPLATES = %w[action api index resource runtime types].to_h do |name|
|
|
9
|
+
[name, File.read(File.join(TEMPLATE_ROOT, "#{name}.ts.erb"), encoding: 'UTF-8').freeze]
|
|
10
|
+
end.freeze
|
|
11
|
+
IO_SUFFIXES = {
|
|
12
|
+
input: 'Input', output: 'Output', global_input: 'GlobalInput',
|
|
13
|
+
global_output: 'GlobalOutput', object_input: 'ObjectInput', object_output: 'ObjectOutput'
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(ir, config)
|
|
17
|
+
@ir = ir
|
|
18
|
+
@runtime_import = config.fetch(:runtime_import) { config.fetch('runtime_import') }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Sorted relative filenames and deterministic UTF-8/LF source; no side effects.
|
|
22
|
+
def files
|
|
23
|
+
plan = file_plan
|
|
24
|
+
validate_paths(plan)
|
|
25
|
+
@resource_paths = plan.select { |item| item[:template] == 'resource' }.to_h { |item| [item[:record].fetch(:schema_path), item[:path]] }
|
|
26
|
+
@action_paths = plan.select { |item| item[:template] == 'action' }.to_h { |item| [item[:record].fetch(:schema_path), item[:path]] }
|
|
27
|
+
plan.sort_by { |item| item.fetch(:path) }.to_h do |item|
|
|
28
|
+
@current_path = item.fetch(:path)
|
|
29
|
+
@record = item[:record]
|
|
30
|
+
content = ERB.new(TEMPLATES.fetch(item.fetch(:template)), trim_mode: '-').result(binding)
|
|
31
|
+
[@current_path, content.gsub("\r\n", "\n").encode('UTF-8')]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def file_plan
|
|
38
|
+
plan = %w[api index runtime types].map { |name| { path: "#{name}.ts", template: name, origin: "generated root #{name}" } }
|
|
39
|
+
walk = lambda do |items, parent|
|
|
40
|
+
items.each do |resource|
|
|
41
|
+
directory = "#{parent}resources/#{resource.fetch(:accessor_name)}/"
|
|
42
|
+
plan << { path: "#{directory}resource.ts", template: 'resource', record: resource, origin: resource.fetch(:schema_path).join('/') }
|
|
43
|
+
resource.fetch(:actions).each do |action|
|
|
44
|
+
plan << { path: "#{directory}actions/#{action.fetch(:accessor_name)}.ts", template: 'action', record: action, origin: action.fetch(:schema_path).join('/') }
|
|
45
|
+
end
|
|
46
|
+
walk.call(resource.fetch(:children), directory)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
walk.call(@ir.fetch(:resources), '')
|
|
50
|
+
plan
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_paths(plan)
|
|
54
|
+
entries = {}
|
|
55
|
+
plan.each do |item|
|
|
56
|
+
path = item.fetch(:path)
|
|
57
|
+
segments = path.split('/', -1)
|
|
58
|
+
unless path.ascii_only? && !path.match?(/[\\\x00-\x1f\x7f]/) && segments.all? { |segment| segment.match?(/\A[a-zA-Z0-9_$.]+\z/) && !%w[. ..].include?(segment) }
|
|
59
|
+
raise Error, "unsafe generated path #{path.inspect} for #{item.fetch(:origin)}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
segments.each_index do |index|
|
|
63
|
+
prefix = segments.take(index + 1).join('/')
|
|
64
|
+
key = prefix.downcase
|
|
65
|
+
kind = index == segments.length - 1 ? :file : :directory
|
|
66
|
+
previous = entries[key]
|
|
67
|
+
if previous && (previous[:path] != prefix || previous[:kind] != kind || kind == :file)
|
|
68
|
+
raise Error, "generated path collision #{prefix}: #{previous[:origin]} (#{previous[:path]}) and #{item.fetch(:origin)} (#{path})"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
entries[key] ||= { path: prefix, kind:, origin: item.fetch(:origin) }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def relative_import(target)
|
|
77
|
+
from = @current_path.split('/')[0...-1]
|
|
78
|
+
to = target.split('/')
|
|
79
|
+
while !from.empty? && from.first == to.first
|
|
80
|
+
from.shift
|
|
81
|
+
to.shift
|
|
82
|
+
end
|
|
83
|
+
path = (Array.new(from.length, '..') + to).join('/').sub(/\.ts\z/, '.js')
|
|
84
|
+
path.start_with?('.') ? path : "./#{path}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def public_type_exports
|
|
88
|
+
resource_exports = resources.map do |resource|
|
|
89
|
+
[@resource_paths.fetch(resource.fetch(:schema_path)), ["#{resource.fetch(:export_name)}Resource"]]
|
|
90
|
+
end
|
|
91
|
+
action_exports = @ir.fetch(:actions).map do |action|
|
|
92
|
+
names = [*IO_SUFFIXES.values, 'Path', 'Options'].map { |suffix| action_type(action, suffix) }
|
|
93
|
+
[@action_paths.fetch(action.fetch(:schema_path)), names]
|
|
94
|
+
end
|
|
95
|
+
(resource_exports + action_exports).sort.map do |path, names|
|
|
96
|
+
"export type {#{names.join(', ')}} from #{literal(relative_import(path))};"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def literal(value)
|
|
101
|
+
JSON.generate(value).gsub("\u2028", '\\u2028').gsub("\u2029", '\\u2029')
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def resources
|
|
105
|
+
flatten = lambda do |items|
|
|
106
|
+
items.flat_map { |resource| [resource] + flatten.call(resource.fetch(:children)) }
|
|
107
|
+
end
|
|
108
|
+
flatten.call(@ir.fetch(:resources))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def action_type(action, suffix)
|
|
112
|
+
"#{action.fetch(:export_name)}#{suffix}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def parameter_type(parameter, input:, query: false)
|
|
116
|
+
kind = parameter.fetch(:kind)
|
|
117
|
+
case kind
|
|
118
|
+
when 'String', 'Text', 'Datetime' then 'string'
|
|
119
|
+
when 'Boolean' then 'boolean'
|
|
120
|
+
when 'Integer', 'Float' then 'number'
|
|
121
|
+
when 'Resource'
|
|
122
|
+
return 'number' if input
|
|
123
|
+
|
|
124
|
+
association = parameter.fetch(:association)
|
|
125
|
+
keys = [association.fetch(:value_id), association.fetch(:value_label)].uniq
|
|
126
|
+
"Partial<Record<#{keys.map { |key| literal(key) }.join(' | ')}, unknown>>"
|
|
127
|
+
when 'Custom'
|
|
128
|
+
return 'unknown' unless input
|
|
129
|
+
|
|
130
|
+
query ? 'string | number | boolean' : 'Exclude<JsonValue, null>'
|
|
131
|
+
else
|
|
132
|
+
raise Error, "unsupported emitter parameter kind #{kind}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def fields(io, input:, query: false)
|
|
137
|
+
return [] unless io
|
|
138
|
+
|
|
139
|
+
io.fetch(:parameters).map do |parameter|
|
|
140
|
+
# Output is an unchanged wire DTO. Input accessors map back to wire keys.
|
|
141
|
+
name = parameter.fetch(input ? :emitted_name : :wire_name)
|
|
142
|
+
optional = !input || !parameter.fetch(:required)
|
|
143
|
+
nullable = !input || parameter.fetch(:nullable)
|
|
144
|
+
type = parameter_type(parameter, input:, query:)
|
|
145
|
+
type += ' | null' if nullable && type != 'unknown'
|
|
146
|
+
type += ' | undefined' if input && optional
|
|
147
|
+
" #{literal(name)}#{'?' if optional}: #{type};"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def required?(io)
|
|
152
|
+
io && io.fetch(:parameters).any? { |parameter| parameter.fetch(:required) }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def params_required?(action)
|
|
156
|
+
required?(action[:input]) || required?(action[:object_input])
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def options_required?(action)
|
|
160
|
+
!action.fetch(:placeholders).empty? || params_required?(action) || required?(action[:global_input])
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def action_declarations(action)
|
|
164
|
+
query = %w[GET OPTIONS].include?(action.fetch(:method))
|
|
165
|
+
declarations = []
|
|
166
|
+
IO_SUFFIXES.each do |key, suffix|
|
|
167
|
+
io = action[key]
|
|
168
|
+
input = key.to_s.end_with?('input')
|
|
169
|
+
lines = fields(io, input:, query: input && query)
|
|
170
|
+
if key == :input && action[:object_input]
|
|
171
|
+
namespace = @ir.fetch(:metadata_namespace)
|
|
172
|
+
if io && io.fetch(:parameters).any? { |parameter| [parameter[:wire_name], parameter[:emitted_name]].include?(namespace) }
|
|
173
|
+
raise Error, "object metadata collides with input field at #{action.fetch(:schema_path).join('/')}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
optional = !required?(action[:object_input])
|
|
177
|
+
lines << " #{literal(namespace)}#{'?' if optional}: #{action_type(action, 'ObjectInput')}#{' | undefined' if optional};"
|
|
178
|
+
elsif key == :output && io && io.fetch(:layout) == 'object_list' && action[:object_output]
|
|
179
|
+
lines << " #{literal(@ir.fetch(:metadata_namespace))}?: #{action_type(action, 'ObjectOutput')} | null;"
|
|
180
|
+
end
|
|
181
|
+
shape = lines.empty? ? 'Record<string, never>' : "{\n#{lines.join("\n")}\n}"
|
|
182
|
+
shape = "Array<#{shape}>" if io && io.fetch(:layout).end_with?('_list')
|
|
183
|
+
shape = 'unknown' if key == :output && io.nil?
|
|
184
|
+
declarations << "export type #{action_type(action, suffix)} = #{shape};"
|
|
185
|
+
end
|
|
186
|
+
paths = action.fetch(:placeholders).map { |name| " #{literal(Naming.identifier(name))}: string | number;" }
|
|
187
|
+
declarations << "export type #{action_type(action, 'Path')} = #{paths.empty? ? 'Record<string, never>' : "{\n#{paths.join("\n")}\n}"};"
|
|
188
|
+
options = []
|
|
189
|
+
options << " path#{'?' if paths.empty?}: #{paths.empty? ? 'undefined' : action_type(action, 'Path')};"
|
|
190
|
+
has_params = action[:input] || action[:object_input]
|
|
191
|
+
params_type = has_params ? action_type(action, 'Input') : 'undefined'
|
|
192
|
+
params_type += ' | undefined' if has_params && !params_required?(action)
|
|
193
|
+
options << " params#{'?' unless params_required?(action)}: #{params_type};"
|
|
194
|
+
meta_type = action[:global_input] ? action_type(action, 'GlobalInput') : 'undefined'
|
|
195
|
+
meta_type += ' | undefined' if action[:global_input] && !required?(action[:global_input])
|
|
196
|
+
options << " meta#{'?' unless required?(action[:global_input])}: #{meta_type};"
|
|
197
|
+
options << " headers?: RequestSpec['headers'];"
|
|
198
|
+
options << " signal?: RequestSpec['signal'];"
|
|
199
|
+
options << " timeoutMs?: RequestSpec['timeoutMs'];"
|
|
200
|
+
declarations << "export interface #{action_type(action, 'Options')} {\n#{options.join("\n")}\n}"
|
|
201
|
+
declarations.join("\n\n")
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def action_names(action)
|
|
205
|
+
[action.fetch(:accessor_name)] + action.fetch(:aliases).map { |name| Naming.identifier(name) }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def resource_declaration(resource)
|
|
209
|
+
members = resource.fetch(:children).map do |child|
|
|
210
|
+
" readonly #{literal(child.fetch(:accessor_name))}: #{child.fetch(:export_name)}Resource;"
|
|
211
|
+
end
|
|
212
|
+
resource.fetch(:actions).each do |action|
|
|
213
|
+
action_names(action).each do |name|
|
|
214
|
+
members << " readonly #{literal(name)}: (options#{'?' unless options_required?(action)}: #{action_type(action, 'Options')}) => Promise<RequestResult<#{action_type(action, 'Output')}>>;"
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
"export interface #{resource.fetch(:export_name)}Resource {\n#{members.join("\n")}\n}"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def field_definitions(io)
|
|
221
|
+
return nil unless io
|
|
222
|
+
|
|
223
|
+
io.fetch(:parameters).map do |parameter|
|
|
224
|
+
parameter.slice(:wire_name, :emitted_name, :kind, :required, :nullable)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def action_definition(action)
|
|
229
|
+
{
|
|
230
|
+
method: action.fetch(:method), path: action.fetch(:path_template),
|
|
231
|
+
placeholders: action.fetch(:placeholders).map { |name| [name, Naming.identifier(name)] },
|
|
232
|
+
inputNamespace: (action[:input] && action[:input][:namespace]) || nil,
|
|
233
|
+
outputNamespace: (action[:output] && action[:output][:namespace]) || nil,
|
|
234
|
+
metaNamespace: @ir.fetch(:metadata_namespace), input: field_definitions(action[:input]),
|
|
235
|
+
globalInput: field_definitions(action[:global_input]), objectInput: field_definitions(action[:object_input])
|
|
236
|
+
}
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|