haveapi-typescript-client 0.32.1 → 0.32.2
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/README.md +54 -2
- data/docs/documentation.md +2 -2
- data/haveapi-typescript-client.gemspec +1 -1
- data/lib/haveapi/typescript_client/documentation.rb +24 -4
- data/lib/haveapi/typescript_client/emitter.rb +18 -7
- data/lib/haveapi/typescript_client/ir.rb +20 -3
- data/lib/haveapi/typescript_client/naming.rb +5 -1
- data/lib/haveapi/typescript_client/version.rb +1 -1
- data/template/action.ts.erb +10 -5
- data/template/api.ts.erb +8 -3
- data/template/descriptions.ts.erb +64 -0
- data/template/index.ts.erb +3 -1
- data/template/resource.ts.erb +4 -5
- data/template/runtime.ts.erb +83 -9
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 93c27159dde8cece29caa28dff63ff8cf2c79d7e637bc9417fe8e7c9619365a3
|
|
4
|
+
data.tar.gz: 4bc3f5b3466df58c7328fd5edd41fab0aaf333990f4b2b9f4d35e7645f7f79be
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 44925fd9911f1592657f2d3462ca4ea771deb62e9a6fc53669b04c8047eb1a0ae44c33a94b22b32ea001b8fdaeca24d90fa2ccd6014999624a98b7f132deb281
|
|
7
|
+
data.tar.gz: a0ffc483c8e72a45e4a9f8a04a20145bf11475b2379574cfffe86c53266539f82fbd676b522a74b7b907f5d0de8b76fb9f2d6bd0b0ee3f2541e603cc27887d7f
|
data/README.md
CHANGED
|
@@ -36,7 +36,7 @@ checkout revision and resolved dependency inputs. Generation reads local files
|
|
|
36
36
|
using the prepared dependencies and runs without contacting the API.
|
|
37
37
|
|
|
38
38
|
Applications need a TypeScript compiler and the `haveapi-client` JavaScript
|
|
39
|
-
runtime. The supported runtime version is exactly `0.
|
|
39
|
+
runtime. The supported runtime version is exactly `0.32.0`.
|
|
40
40
|
|
|
41
41
|
Both generation and `generate --check` require **Prettier 3.9.10** and Node.
|
|
42
42
|
The repository's default Nix shell supplies them. For an installed Ruby gem,
|
|
@@ -123,7 +123,7 @@ and a TypeScript compiler:
|
|
|
123
123
|
|
|
124
124
|
```sh
|
|
125
125
|
cd /path/to/widget-client
|
|
126
|
-
npm install --save-exact haveapi-client@0.
|
|
126
|
+
npm install --save-exact haveapi-client@0.32.0
|
|
127
127
|
npm install --save-dev typescript
|
|
128
128
|
```
|
|
129
129
|
|
|
@@ -156,6 +156,57 @@ Raw request results cannot be passed to
|
|
|
156
156
|
`AsyncClient.waitForCompletion`; see the
|
|
157
157
|
[JavaScript runtime guide](../js/README.md#advanced-usage-and-reference).
|
|
158
158
|
|
|
159
|
+
Each action returns `ActionResult<Output, GlobalOutput>`. Its `meta` has the
|
|
160
|
+
declared global output fields, preserving optional/null values, and accepts
|
|
161
|
+
additional unknown server fields. The complete envelope and HTTP status remain
|
|
162
|
+
available. This typing does not validate payload or metadata fields at runtime.
|
|
163
|
+
|
|
164
|
+
Read live descriptions through the same executor:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
const description = await api.describe({signal: new AbortController().signal});
|
|
168
|
+
const capability = await api.widget.update.describe({path: {widget_id: 1}});
|
|
169
|
+
const required = capability.data.input?.parameters?.['name']?.required;
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`api.describe` sends OPTIONS to the selected version root. An action's `describe`
|
|
173
|
+
uses its saved help route and encoded IDs, with the recorded HTTP method selector;
|
|
174
|
+
older snapshots without help use the validated action route and method.
|
|
175
|
+
Explicit help without the action's method selector is rejected during generation.
|
|
176
|
+
Headers, signal and timeout can be supplied, but arbitrary HTTP routes and
|
|
177
|
+
mutation input cannot. Aliases share the same callable action and `describe`
|
|
178
|
+
function. A root resource named `describe` is rejected as a name collision
|
|
179
|
+
during generation.
|
|
180
|
+
The exported protocol description types are conservative and extensible. Fields
|
|
181
|
+
can be absent or null, and consumers must narrow the parts they use. The
|
|
182
|
+
executor retains responsibility for envelope errors. Static generated client
|
|
183
|
+
action presence does not establish authorization or field availability; live
|
|
184
|
+
descriptions can hide actions/fields or fail. The generator does not cache
|
|
185
|
+
descriptions or infer permissions from them.
|
|
186
|
+
|
|
187
|
+
Generated local input, JSON and path checks throw `ClientInputError`, a
|
|
188
|
+
`TypeError` subclass with `code: 'HAVEAPI_CLIENT_INPUT_ERROR'` and
|
|
189
|
+
`phase: 'pre-dispatch'`. This error means the executor was never called, so no
|
|
190
|
+
mutation was sent. Errors from the executor pass through unchanged; an ordinary
|
|
191
|
+
transport `TypeError` must not be treated as a proven local failure. Error
|
|
192
|
+
objects do not retain input values.
|
|
193
|
+
|
|
194
|
+
When the deployed API uses a different metadata namespace, bind one override:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
const api = createClient({execute: spec => runtime.request(spec), metaNamespace: 'meta'});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The default is the saved schema's namespace. Generated input keys remain those
|
|
201
|
+
in the schema (for example `params._meta`); the generator maps declared object
|
|
202
|
+
metadata to the effective wire key and sets `RequestSpec.metaNamespace` for
|
|
203
|
+
ordinary and description requests. Global metadata still uses `options.meta`.
|
|
204
|
+
For declared object-list output metadata, `data` exposes the canonical schema
|
|
205
|
+
key. The projection preserves Custom/nested values and the original envelope;
|
|
206
|
+
it never walks arbitrary payloads. Invalid/reserved namespace overrides or
|
|
207
|
+
collisions with declared input/output fields fail before dispatch. Bind a new
|
|
208
|
+
client when the effective namespace changes.
|
|
209
|
+
|
|
159
210
|
## Schemas and types
|
|
160
211
|
|
|
161
212
|
Input is a format 1 snapshot with `api_version`, `protocol_version` (major 2),
|
|
@@ -189,6 +240,7 @@ src/generated/
|
|
|
189
240
|
index.ts
|
|
190
241
|
api.ts
|
|
191
242
|
runtime.ts
|
|
243
|
+
descriptions.ts
|
|
192
244
|
types.ts
|
|
193
245
|
resources/
|
|
194
246
|
widget/
|
data/docs/documentation.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The Ruby server can show examples for a generated TypeScript SDK alongside its
|
|
4
4
|
dynamic TypeScript clients. The generated examples use the SDK's public
|
|
5
|
-
`createClient` factory and `haveapi-client` version `0.
|
|
5
|
+
`createClient` factory and `haveapi-client` version `0.32.0` for requests and
|
|
6
6
|
authentication. The server prepares the SDK model at mount time and renders
|
|
7
7
|
examples when users view the documentation.
|
|
8
8
|
|
|
@@ -103,7 +103,7 @@ TypeScript 6, NodeNext/ES2022, `exactOptionalPropertyTypes`,
|
|
|
103
103
|
`noUncheckedIndexedAccess` and `verbatimModuleSyntax`. They cover aliases, nested
|
|
104
104
|
paths, filtered fields, stale contracts, supported authentication and errors.
|
|
105
105
|
|
|
106
|
-
Run the wire harness with a prepared published `haveapi-client@0.
|
|
106
|
+
Run the wire harness with a prepared published `haveapi-client@0.32.0`
|
|
107
107
|
installation:
|
|
108
108
|
|
|
109
109
|
```sh
|
|
@@ -56,7 +56,7 @@ module HaveAPI::TypeScriptClient
|
|
|
56
56
|
{ status: :code, code: source.freeze }.freeze
|
|
57
57
|
rescue Unsupported, JSON::GeneratorError, EncodingError
|
|
58
58
|
# Never name fields or actions from the private API client in public diagnostics.
|
|
59
|
-
{ status: :limitation, reason: 'This sample is unavailable or incompatible with the configured TypeScript API client.'.freeze }.freeze
|
|
59
|
+
{ status: :limitation, reason: documentation_message('generated.typescript.unavailable', context:, default: 'This sample is unavailable or incompatible with the configured TypeScript API client.').freeze }.freeze
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
def detail_levels_for(kind:, context:, result:, mode:, auth_method: nil, auth_description: nil)
|
|
@@ -67,6 +67,26 @@ module HaveAPI::TypeScriptClient
|
|
|
67
67
|
|
|
68
68
|
private
|
|
69
69
|
|
|
70
|
+
def documentation_message(key, context:, default:, **values)
|
|
71
|
+
if context.respond_to?(:documentation_message)
|
|
72
|
+
context.documentation_message(key, default:, **values)
|
|
73
|
+
else
|
|
74
|
+
values.empty? ? default : format(default, values)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def example_comment(key, context:, default:, **values)
|
|
79
|
+
if context.respond_to?(:example_comment)
|
|
80
|
+
context.example_comment(key, default:, **values)
|
|
81
|
+
else
|
|
82
|
+
values.empty? ? default : format(default, values)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def comment_lines(text, prefix)
|
|
87
|
+
text.gsub(/\r\n?/, "\n").split("\n", -1).map { |line| "#{prefix}#{line}" }.join("\n")
|
|
88
|
+
end
|
|
89
|
+
|
|
70
90
|
def unsupported!
|
|
71
91
|
raise Unsupported
|
|
72
92
|
end
|
|
@@ -125,7 +145,7 @@ module HaveAPI::TypeScriptClient
|
|
|
125
145
|
|
|
126
146
|
def program(context, body, auth = [], errors: false)
|
|
127
147
|
version_url = version_url(context)
|
|
128
|
-
dependencies =
|
|
148
|
+
dependencies = comment_lines(example_comment('typescript_package', default: 'Configured API client: %{package}.', context:, package: "#{@package_name}#{"@#{@package_version}" if @package_version}"), '// ')
|
|
129
149
|
runtime_imports = errors ? 'AsyncClient, AsyncError' : 'AsyncClient'
|
|
130
150
|
lines = [dependencies, "import { #{runtime_imports} } from #{literal('haveapi-client')};", "import { createClient } from #{literal(@package_name)};"]
|
|
131
151
|
lines += ['', 'async function main(): Promise<void> {']
|
|
@@ -327,14 +347,14 @@ module HaveAPI::TypeScriptClient
|
|
|
327
347
|
body << 'void [result.data, result.meta, result.envelope, result.httpStatus];' if context.detail == :thorough
|
|
328
348
|
body.concat(output_lines(action, response, thorough: context.detail == :thorough))
|
|
329
349
|
end
|
|
330
|
-
body << '
|
|
350
|
+
body << comment_lines(example_comment('typescript_completion', default: 'Request success does not establish completion of a server-side operation.', context:), '// ') if action.fetch(:blocking)
|
|
331
351
|
auth = action_authentication(action, context)
|
|
332
352
|
if context.detail == :thorough
|
|
333
353
|
return program(context, body, auth, errors: true)
|
|
334
354
|
end
|
|
335
355
|
|
|
336
356
|
imports = failure ? ["import { AsyncError } from #{literal('haveapi-client')};", ''] : []
|
|
337
|
-
(imports + ['
|
|
357
|
+
(imports + [comment_lines(example_comment('typescript_async_context', default: 'Requires an initialized api and an async context.', context:), '// ')] + body + ['']).join("\n")
|
|
338
358
|
end
|
|
339
359
|
|
|
340
360
|
def output_lines(action, response, thorough:)
|
|
@@ -5,7 +5,7 @@ module HaveAPI::TypeScriptClient
|
|
|
5
5
|
# Templates are source inputs loaded once, outside the pure emission seam.
|
|
6
6
|
class Emitter
|
|
7
7
|
TEMPLATE_ROOT = File.expand_path('../../../template', __dir__).freeze
|
|
8
|
-
TEMPLATES = %w[action api index resource runtime types].to_h do |name|
|
|
8
|
+
TEMPLATES = %w[action api descriptions index resource runtime types].to_h do |name|
|
|
9
9
|
[name, File.read(File.join(TEMPLATE_ROOT, "#{name}.ts.erb"), encoding: 'UTF-8').freeze]
|
|
10
10
|
end.freeze
|
|
11
11
|
IO_SUFFIXES = {
|
|
@@ -35,7 +35,7 @@ module HaveAPI::TypeScriptClient
|
|
|
35
35
|
private
|
|
36
36
|
|
|
37
37
|
def file_plan
|
|
38
|
-
plan = %w[api index runtime types].map { |name| { path: "#{name}.ts", template: name, origin: "generated root #{name}" } }
|
|
38
|
+
plan = %w[api descriptions index runtime types].map { |name| { path: "#{name}.ts", template: name, origin: "generated root #{name}" } }
|
|
39
39
|
walk = lambda do |items, parent|
|
|
40
40
|
items.each do |resource|
|
|
41
41
|
directory = "#{parent}resources/#{resource.fetch(:accessor_name)}/"
|
|
@@ -89,7 +89,7 @@ module HaveAPI::TypeScriptClient
|
|
|
89
89
|
[@resource_paths.fetch(resource.fetch(:schema_path)), ["#{resource.fetch(:export_name)}Resource"]]
|
|
90
90
|
end
|
|
91
91
|
action_exports = @ir.fetch(:actions).map do |action|
|
|
92
|
-
names = [*IO_SUFFIXES.values, 'Path', 'Options'].map { |suffix| action_type(action, suffix) }
|
|
92
|
+
names = [*IO_SUFFIXES.values, 'Path', 'Options', 'DescribeOptions', 'Action'].map { |suffix| action_type(action, suffix) }
|
|
93
93
|
[@action_paths.fetch(action.fetch(:schema_path)), names]
|
|
94
94
|
end
|
|
95
95
|
(resource_exports + action_exports).sort.map do |path, names|
|
|
@@ -178,7 +178,8 @@ module HaveAPI::TypeScriptClient
|
|
|
178
178
|
elsif key == :output && io && io.fetch(:layout) == 'object_list' && action[:object_output]
|
|
179
179
|
lines << " #{literal(@ir.fetch(:metadata_namespace))}?: #{action_type(action, 'ObjectOutput')} | null;"
|
|
180
180
|
end
|
|
181
|
-
|
|
181
|
+
empty = key == :global_output ? 'Record<never, never>' : 'Record<string, never>'
|
|
182
|
+
shape = lines.empty? ? empty : "{\n#{lines.join("\n")}\n}"
|
|
182
183
|
shape = "Array<#{shape}>" if io && io.fetch(:layout).end_with?('_list')
|
|
183
184
|
shape = 'unknown' if key == :output && io.nil?
|
|
184
185
|
declarations << "export type #{action_type(action, suffix)} = #{shape};"
|
|
@@ -198,6 +199,13 @@ module HaveAPI::TypeScriptClient
|
|
|
198
199
|
options << " signal?: RequestSpec['signal'];"
|
|
199
200
|
options << " timeoutMs?: RequestSpec['timeoutMs'];"
|
|
200
201
|
declarations << "export interface #{action_type(action, 'Options')} {\n#{options.join("\n")}\n}"
|
|
202
|
+
declarations << "export interface #{action_type(action, 'DescribeOptions')} extends DescribeOptions {\n path#{'?' if paths.empty?}: #{paths.empty? ? 'undefined' : action_type(action, 'Path')};\n}"
|
|
203
|
+
declarations << <<~TS.chomp
|
|
204
|
+
export interface #{action_type(action, 'Action')} {
|
|
205
|
+
(options#{'?' unless options_required?(action)}: #{action_type(action, 'Options')}): Promise<ActionResult<#{action_type(action, 'Output')}, #{action_type(action, 'GlobalOutput')}>>;
|
|
206
|
+
describe(options#{'?' if paths.empty?}: #{action_type(action, 'DescribeOptions')}): Promise<ActionResult<ActionDescription>>;
|
|
207
|
+
}
|
|
208
|
+
TS
|
|
201
209
|
declarations.join("\n\n")
|
|
202
210
|
end
|
|
203
211
|
|
|
@@ -211,7 +219,7 @@ module HaveAPI::TypeScriptClient
|
|
|
211
219
|
end
|
|
212
220
|
resource.fetch(:actions).each do |action|
|
|
213
221
|
action_names(action).each do |name|
|
|
214
|
-
members << " readonly #{literal(name)}:
|
|
222
|
+
members << " readonly #{literal(name)}: #{action_type(action, 'Action')};"
|
|
215
223
|
end
|
|
216
224
|
end
|
|
217
225
|
"export interface #{resource.fetch(:export_name)}Resource {\n#{members.join("\n")}\n}"
|
|
@@ -226,13 +234,16 @@ module HaveAPI::TypeScriptClient
|
|
|
226
234
|
end
|
|
227
235
|
|
|
228
236
|
def action_definition(action)
|
|
237
|
+
object_output = action[:object_output] && action[:output]&.fetch(:layout) == 'object_list'
|
|
229
238
|
{
|
|
230
|
-
method: action.fetch(:method), path: action.fetch(:path_template),
|
|
239
|
+
method: action.fetch(:method), path: action.fetch(:path_template), helpPath: action.fetch(:help_path),
|
|
231
240
|
placeholders: action.fetch(:placeholders).map { |name| [name, Naming.identifier(name)] },
|
|
232
241
|
inputNamespace: (action[:input] && action[:input][:namespace]) || nil,
|
|
233
242
|
outputNamespace: (action[:output] && action[:output][:namespace]) || nil,
|
|
234
243
|
metaNamespace: @ir.fetch(:metadata_namespace), input: field_definitions(action[:input]),
|
|
235
|
-
globalInput: field_definitions(action[:global_input]), objectInput: field_definitions(action[:object_input])
|
|
244
|
+
globalInput: field_definitions(action[:global_input]), objectInput: field_definitions(action[:object_input]),
|
|
245
|
+
outputNames: object_output ? action[:output].fetch(:parameters).map { |field| field.fetch(:wire_name) } : nil,
|
|
246
|
+
objectOutput: object_output
|
|
236
247
|
}
|
|
237
248
|
end
|
|
238
249
|
end
|
|
@@ -44,7 +44,7 @@ module HaveAPI::TypeScriptClient
|
|
|
44
44
|
def walk(map, parents, authentication: false)
|
|
45
45
|
fail_at('resources must be an object', parents) unless map.is_a?(Hash)
|
|
46
46
|
|
|
47
|
-
scope = Naming::Scope.new
|
|
47
|
+
scope = Naming::Scope.new(parents.empty? && !authentication ? Naming::RESERVED + ['describe'] : Naming::RESERVED)
|
|
48
48
|
map.sort.map do |name, desc|
|
|
49
49
|
path = parents + [name]
|
|
50
50
|
fail_at('resource must be an object', path) unless desc.is_a?(Hash)
|
|
@@ -71,7 +71,7 @@ module HaveAPI::TypeScriptClient
|
|
|
71
71
|
fail_at('action must be an object', path) unless desc.is_a?(Hash)
|
|
72
72
|
accessor = scope.add(Naming.identifier(name), path.join('/'))
|
|
73
73
|
export = Naming.export(path)
|
|
74
|
-
%w[Path Input Output GlobalInput GlobalOutput ObjectInput ObjectOutput Options].each do |suffix|
|
|
74
|
+
%w[Path Input Output GlobalInput GlobalOutput ObjectInput ObjectOutput Options DescribeOptions Action].each do |suffix|
|
|
75
75
|
@exports.add("#{export}#{suffix}", "#{path.join('/')} (#{suffix})")
|
|
76
76
|
end
|
|
77
77
|
aliases = desc.fetch('aliases', [])
|
|
@@ -88,7 +88,8 @@ module HaveAPI::TypeScriptClient
|
|
|
88
88
|
placeholders.each { |item| placeholder_scope.add(Naming.identifier(item), (path + ['path', item]).join('/')) }
|
|
89
89
|
action = {
|
|
90
90
|
schema_path: path, export_name: export, accessor_name: accessor, aliases:, method: desc['method'],
|
|
91
|
-
path_template: route,
|
|
91
|
+
path_template: route, help_path: help_route(desc, route, path, placeholders, authentication:),
|
|
92
|
+
placeholders:, auth: desc.fetch('auth', true) != false, blocking: desc['blocking'] == true,
|
|
92
93
|
input: io(desc['input'], path + ['input'], input: true), output: io(desc['output'], path + ['output'])
|
|
93
94
|
}
|
|
94
95
|
metadata = desc['meta'] || {}
|
|
@@ -186,6 +187,22 @@ module HaveAPI::TypeScriptClient
|
|
|
186
187
|
"/#{relative}"
|
|
187
188
|
end
|
|
188
189
|
|
|
190
|
+
def help_route(desc, action_route, path, placeholders, authentication:)
|
|
191
|
+
return "#{action_route}?method=#{desc['method']}" unless desc.has_key?('help')
|
|
192
|
+
|
|
193
|
+
help = desc['help']
|
|
194
|
+
fail_at('invalid action help route', path) unless help.is_a?(String)
|
|
195
|
+
help_path, query = help.split('?', -1)
|
|
196
|
+
unless query == "method=#{desc['method']}"
|
|
197
|
+
fail_at('action help method differs from action method', path)
|
|
198
|
+
end
|
|
199
|
+
relative = route(help_path, path + ['help'], authentication:)
|
|
200
|
+
unless relative.scan(/\{([^}]+)\}/).flatten.uniq.sort == placeholders.sort
|
|
201
|
+
fail_at('action help placeholders differ from action path', path)
|
|
202
|
+
end
|
|
203
|
+
"#{relative}?#{query}"
|
|
204
|
+
end
|
|
205
|
+
|
|
189
206
|
def freeze_data(value)
|
|
190
207
|
case value
|
|
191
208
|
when Hash
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
module HaveAPI::TypeScriptClient
|
|
2
2
|
module Naming
|
|
3
3
|
RESERVED = %w[then constructor prototype __proto__].freeze
|
|
4
|
-
EXPORT_RESERVED = %w[
|
|
4
|
+
EXPORT_RESERVED = %w[
|
|
5
|
+
createClient RequestExecutor ClientOptions JsonValue API_VERSION SCHEMA_SHA256 RequestOptions executeAction
|
|
6
|
+
ActionResult ClientInputError ApiDescription ActionDescription ResourceDescription ParameterDescription
|
|
7
|
+
IoDescription MetadataDescription AuthenticationDescription DescribeOptions
|
|
8
|
+
].freeze
|
|
5
9
|
|
|
6
10
|
def self.identifier(name)
|
|
7
11
|
raise Error, 'schema name must be a nonempty string' unless name.is_a?(String) && !name.empty?
|
data/template/action.ts.erb
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
// Generated by HaveAPI. Regenerate from the recorded schema; do not edit.
|
|
2
|
-
import type {RequestSpec
|
|
3
|
-
import {executeAction} from <%= literal(relative_import('runtime.ts')) %>;
|
|
4
|
-
import type {
|
|
2
|
+
import type {RequestSpec} from <%= literal(@runtime_import) %>;
|
|
3
|
+
import {executeAction, describeAction, metadataNamespace} from <%= literal(relative_import('runtime.ts')) %>;
|
|
4
|
+
import type {ActionDescription} from <%= literal(relative_import('descriptions.ts')) %>;
|
|
5
|
+
import type {ActionResult, DescribeOptions, JsonValue, RequestExecutor} from <%= literal(relative_import('runtime.ts')) %>;
|
|
5
6
|
|
|
6
7
|
<%= action_declarations(@record) %>
|
|
7
8
|
|
|
8
9
|
const descriptor = <%= literal(action_definition(@record)) %> as const;
|
|
9
10
|
|
|
10
11
|
/** Internal operation binding; public callers use createClient. */
|
|
11
|
-
export function createAction(execute: RequestExecutor
|
|
12
|
-
|
|
12
|
+
export function createAction(execute: RequestExecutor, metaNamespace?: string): <%= action_type(@record, 'Action') %> {
|
|
13
|
+
const namespace = metadataNamespace(metaNamespace, descriptor);
|
|
14
|
+
const invoke = (options: <%= action_type(@record, 'Options') %><%= ' = {}' unless options_required?(@record) %>) => executeAction<<%= action_type(@record, 'Output') %>, <%= action_type(@record, 'GlobalOutput') %>>(execute, descriptor, options, namespace);
|
|
15
|
+
return Object.assign(invoke, {
|
|
16
|
+
describe: (options: <%= action_type(@record, 'DescribeOptions') %><%= ' = {}' if @record.fetch(:placeholders).empty? %>) => describeAction(execute, descriptor, options, namespace),
|
|
17
|
+
});
|
|
13
18
|
}
|
data/template/api.ts.erb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Generated by HaveAPI. Regenerate from the recorded schema; do not edit.
|
|
2
|
-
import
|
|
2
|
+
import {describeApi, metadataNamespace} from './runtime.js';
|
|
3
|
+
import type {ActionResult, ClientOptions, DescribeOptions} from './runtime.js';
|
|
4
|
+
import type {ApiDescription} from './descriptions.js';
|
|
3
5
|
<% @ir.fetch(:resources).each_with_index do |resource, index| -%>
|
|
4
6
|
import {createResource as resource<%= index %>} from <%= literal(relative_import(@resource_paths.fetch(resource.fetch(:schema_path)))) %>;
|
|
5
7
|
import type {<%= resource.fetch(:export_name) %>Resource} from <%= literal(relative_import(@resource_paths.fetch(resource.fetch(:schema_path)))) %>;
|
|
@@ -15,14 +17,17 @@ export const SCHEMA_SHA256 = <%= literal(@ir.fetch(:schema_sha256)) %>;
|
|
|
15
17
|
* Generated output types do not validate response application fields at runtime.
|
|
16
18
|
* Authentication and completion remain with the consumer's HaveAPI runtime.
|
|
17
19
|
*/
|
|
18
|
-
export function createClient({execute}: ClientOptions): {
|
|
20
|
+
export function createClient({execute, metaNamespace}: ClientOptions): {
|
|
21
|
+
readonly describe: (options?: DescribeOptions) => Promise<ActionResult<ApiDescription>>;
|
|
19
22
|
<% @ir.fetch(:resources).each do |resource| -%>
|
|
20
23
|
readonly <%= literal(resource.fetch(:accessor_name)) %>: <%= resource.fetch(:export_name) %>Resource;
|
|
21
24
|
<% end -%>
|
|
22
25
|
} {
|
|
26
|
+
const namespace = metadataNamespace(metaNamespace);
|
|
23
27
|
return {
|
|
28
|
+
describe: (options: DescribeOptions = {}) => describeApi(execute, options, namespace),
|
|
24
29
|
<% @ir.fetch(:resources).each_with_index do |resource, index| -%>
|
|
25
|
-
<%= literal(resource.fetch(:accessor_name)) %>: resource<%= index %>(execute),
|
|
30
|
+
<%= literal(resource.fetch(:accessor_name)) %>: resource<%= index %>(execute, namespace),
|
|
26
31
|
<% end -%>
|
|
27
32
|
};
|
|
28
33
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Generated by HaveAPI. Regenerate from the recorded schema; do not edit.
|
|
2
|
+
/** Live protocol-2 descriptions are permission-filtered, extensible wire data.
|
|
3
|
+
* Their application fields are not validated by the generated client. */
|
|
4
|
+
export interface ParameterDescription {
|
|
5
|
+
type?: string | null;
|
|
6
|
+
required?: boolean | null;
|
|
7
|
+
nullable?: boolean | null;
|
|
8
|
+
protected?: boolean | null;
|
|
9
|
+
label?: string | null;
|
|
10
|
+
description?: string | null;
|
|
11
|
+
default?: unknown;
|
|
12
|
+
validators?: Record<string, unknown> | null;
|
|
13
|
+
resource?: string[] | null;
|
|
14
|
+
value_id?: string | null;
|
|
15
|
+
value_label?: string | null;
|
|
16
|
+
value?: {path?: string; method?: string; help?: string; [key: string]: unknown} | null;
|
|
17
|
+
choices?: Record<string, unknown> | null;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
export interface IoDescription {
|
|
21
|
+
layout?: 'object' | 'object_list' | 'hash' | 'hash_list' | null;
|
|
22
|
+
namespace?: string | false | null;
|
|
23
|
+
parameters?: Record<string, ParameterDescription> | null;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
export interface MetadataDescription {
|
|
27
|
+
input?: IoDescription | null;
|
|
28
|
+
output?: IoDescription | null;
|
|
29
|
+
[key: string]: unknown;
|
|
30
|
+
}
|
|
31
|
+
export interface ActionDescription {
|
|
32
|
+
auth?: boolean | null;
|
|
33
|
+
blocking?: boolean | null;
|
|
34
|
+
aliases?: string[] | null;
|
|
35
|
+
description?: string | null;
|
|
36
|
+
method?: string | null;
|
|
37
|
+
path?: string | null;
|
|
38
|
+
help?: string | null;
|
|
39
|
+
input?: IoDescription | null;
|
|
40
|
+
output?: IoDescription | null;
|
|
41
|
+
meta?: {global?: MetadataDescription | null; object?: MetadataDescription | null; [key: string]: unknown} | null;
|
|
42
|
+
examples?: unknown[] | null;
|
|
43
|
+
[key: string]: unknown;
|
|
44
|
+
}
|
|
45
|
+
export interface ResourceDescription {
|
|
46
|
+
description?: string | null;
|
|
47
|
+
actions?: Record<string, ActionDescription> | null;
|
|
48
|
+
resources?: Record<string, ResourceDescription> | null;
|
|
49
|
+
[key: string]: unknown;
|
|
50
|
+
}
|
|
51
|
+
export interface AuthenticationDescription {
|
|
52
|
+
http_header?: string | null;
|
|
53
|
+
query_parameter?: string | null;
|
|
54
|
+
revoke_url?: string | null;
|
|
55
|
+
resources?: Record<string, ResourceDescription> | null;
|
|
56
|
+
[key: string]: unknown;
|
|
57
|
+
}
|
|
58
|
+
export interface ApiDescription {
|
|
59
|
+
authentication?: Record<string, AuthenticationDescription> | null;
|
|
60
|
+
resources?: Record<string, ResourceDescription> | null;
|
|
61
|
+
meta?: {namespace?: string | null; [key: string]: unknown} | null;
|
|
62
|
+
help?: string | null;
|
|
63
|
+
[key: string]: unknown;
|
|
64
|
+
}
|
data/template/index.ts.erb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
// Generated by HaveAPI. Regenerate from the recorded schema; do not edit.
|
|
2
2
|
export * from './api.js';
|
|
3
|
-
export
|
|
3
|
+
export {ClientInputError} from './runtime.js';
|
|
4
|
+
export type {ActionResult, ClientOptions, DescribeOptions, JsonValue, RequestExecutor} from './runtime.js';
|
|
5
|
+
export type * from './descriptions.js';
|
data/template/resource.ts.erb
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// Generated by HaveAPI. Regenerate from the recorded schema; do not edit.
|
|
2
|
-
import type {RequestResult} from <%= literal(@runtime_import) %>;
|
|
3
2
|
import type {RequestExecutor} from <%= literal(relative_import('runtime.ts')) %>;
|
|
4
3
|
<% @record.fetch(:children).each_with_index do |child, index| -%>
|
|
5
4
|
import {createResource as child<%= index %>} from <%= literal(relative_import(@resource_paths.fetch(child.fetch(:schema_path)))) %>;
|
|
@@ -7,19 +6,19 @@ import type {<%= child.fetch(:export_name) %>Resource} from <%= literal(relative
|
|
|
7
6
|
<% end -%>
|
|
8
7
|
<% @record.fetch(:actions).each_with_index do |action, index| -%>
|
|
9
8
|
import {createAction as bind<%= index %>} from <%= literal(relative_import(@action_paths.fetch(action.fetch(:schema_path)))) %>;
|
|
10
|
-
import type {<%= action_type(action, '
|
|
9
|
+
import type {<%= action_type(action, 'Action') %>} from <%= literal(relative_import(@action_paths.fetch(action.fetch(:schema_path)))) %>;
|
|
11
10
|
<% end -%>
|
|
12
11
|
|
|
13
12
|
<%= resource_declaration(@record) %>
|
|
14
13
|
|
|
15
14
|
/** Internal resource composition; public callers use createClient. */
|
|
16
|
-
export function createResource(execute: RequestExecutor): <%= @record.fetch(:export_name) %>Resource {
|
|
15
|
+
export function createResource(execute: RequestExecutor, metaNamespace?: string): <%= @record.fetch(:export_name) %>Resource {
|
|
17
16
|
<% @record.fetch(:actions).each_with_index do |_action, index| -%>
|
|
18
|
-
const action<%= index %> = bind<%= index %>(execute);
|
|
17
|
+
const action<%= index %> = bind<%= index %>(execute, metaNamespace);
|
|
19
18
|
<% end -%>
|
|
20
19
|
return {
|
|
21
20
|
<% @record.fetch(:children).each_with_index do |child, index| -%>
|
|
22
|
-
<%= literal(child.fetch(:accessor_name)) %>: child<%= index %>(execute),
|
|
21
|
+
<%= literal(child.fetch(:accessor_name)) %>: child<%= index %>(execute, metaNamespace),
|
|
23
22
|
<% end -%>
|
|
24
23
|
<% @record.fetch(:actions).each_with_index do |action, index| -%>
|
|
25
24
|
<% action_names(action).each do |name| -%>
|
data/template/runtime.ts.erb
CHANGED
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
// Generated by HaveAPI. Regenerate from the recorded schema; do not edit.
|
|
2
2
|
import type {RequestSpec, RequestResult} from <%= literal(@runtime_import) %>;
|
|
3
|
+
import type {ActionDescription, ApiDescription} from './descriptions.js';
|
|
3
4
|
|
|
4
5
|
export type JsonValue = null | boolean | number | string | JsonValue[] | {[key: string]: JsonValue};
|
|
5
6
|
export type RequestExecutor = (spec: RequestSpec) => Promise<RequestResult<unknown>>;
|
|
6
|
-
export interface ClientOptions {execute: RequestExecutor}
|
|
7
|
+
export interface ClientOptions {execute: RequestExecutor; metaNamespace?: string}
|
|
8
|
+
/** Metadata fields retain the action contract and unknown server extensions. */
|
|
9
|
+
export type ActionResult<TData, TMeta = Record<string, unknown>> = Omit<RequestResult<TData>, 'meta'> & {
|
|
10
|
+
meta: TMeta & Record<string, unknown>;
|
|
11
|
+
};
|
|
12
|
+
export interface DescribeOptions {
|
|
13
|
+
headers?: RequestSpec['headers'];
|
|
14
|
+
signal?: RequestSpec['signal'];
|
|
15
|
+
timeoutMs?: RequestSpec['timeoutMs'];
|
|
16
|
+
}
|
|
17
|
+
/** Validation failed locally before the executor was called; no write was sent. */
|
|
18
|
+
export class ClientInputError extends TypeError {
|
|
19
|
+
readonly code = 'HAVEAPI_CLIENT_INPUT_ERROR' as const;
|
|
20
|
+
readonly phase = 'pre-dispatch' as const;
|
|
21
|
+
constructor(message: string) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'ClientInputError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
7
26
|
|
|
8
27
|
interface Field {
|
|
9
28
|
wire_name: string;
|
|
@@ -15,6 +34,7 @@ interface Field {
|
|
|
15
34
|
interface Action {
|
|
16
35
|
method: string;
|
|
17
36
|
path: string;
|
|
37
|
+
helpPath: string;
|
|
18
38
|
placeholders: readonly (readonly [string, string])[];
|
|
19
39
|
inputNamespace: string | null;
|
|
20
40
|
outputNamespace: string | null;
|
|
@@ -22,6 +42,8 @@ interface Action {
|
|
|
22
42
|
input: readonly Field[] | null;
|
|
23
43
|
globalInput: readonly Field[] | null;
|
|
24
44
|
objectInput: readonly Field[] | null;
|
|
45
|
+
outputNames: readonly string[] | null;
|
|
46
|
+
objectOutput: boolean | null;
|
|
25
47
|
}
|
|
26
48
|
interface Options {
|
|
27
49
|
path?: Record<string, string | number> | undefined;
|
|
@@ -39,7 +61,22 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
39
61
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
40
62
|
}
|
|
41
63
|
function fail(message: string): never {
|
|
42
|
-
throw new
|
|
64
|
+
throw new ClientInputError(message);
|
|
65
|
+
}
|
|
66
|
+
/** Bind a single wire namespace; schema-named input/output metadata stays canonical. */
|
|
67
|
+
export function metadataNamespace(override?: string, action?: Action): string {
|
|
68
|
+
const namespace = override === undefined ? <%= literal(@ir.fetch(:metadata_namespace)) %> : override;
|
|
69
|
+
if (typeof namespace !== 'string' || namespace.length === 0 || ['then', 'constructor', 'prototype', '__proto__'].includes(namespace))
|
|
70
|
+
fail('Invalid metadata namespace');
|
|
71
|
+
if (action && namespace !== action.metaNamespace) {
|
|
72
|
+
if (namespace === action.inputNamespace || namespace === action.outputNamespace)
|
|
73
|
+
fail('Metadata namespace conflicts with an action namespace');
|
|
74
|
+
if ((action.objectInput || action.inputNamespace === null) && action.input?.some(field => field.wire_name === namespace))
|
|
75
|
+
fail('Metadata namespace conflicts with an input field');
|
|
76
|
+
if (action.objectOutput && action.outputNames?.includes(namespace))
|
|
77
|
+
fail('Metadata namespace conflicts with an output field');
|
|
78
|
+
}
|
|
79
|
+
return namespace;
|
|
43
80
|
}
|
|
44
81
|
function assertJson(value: unknown, label: string, seen = new Set<object>()): void {
|
|
45
82
|
if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
|
|
@@ -123,7 +160,11 @@ function actionPath(action: Action, values: Options['path']): string {
|
|
|
123
160
|
}
|
|
124
161
|
// URL parsers normalize dot segments even when their dots are percent encoded.
|
|
125
162
|
if (value === '.' || value === '..') fail(`Unsafe path parameter ${name}`);
|
|
126
|
-
|
|
163
|
+
try {
|
|
164
|
+
encoded[wire] = encodeURIComponent(String(value));
|
|
165
|
+
} catch {
|
|
166
|
+
fail(`Invalid path encoding for ${name}`);
|
|
167
|
+
}
|
|
127
168
|
}
|
|
128
169
|
return action.path.replace(/\{([^}]+)\}/g, (_match, name: string) => {
|
|
129
170
|
const value = encoded[name];
|
|
@@ -133,7 +174,7 @@ function actionPath(action: Action, values: Options['path']): string {
|
|
|
133
174
|
}
|
|
134
175
|
|
|
135
176
|
/** One send; the executor owns transport, envelope validation and error semantics. */
|
|
136
|
-
export async function executeAction<T>(execute: RequestExecutor, action: Action, options: Options = {}): Promise<
|
|
177
|
+
export async function executeAction<T, TMeta>(execute: RequestExecutor, action: Action, options: Options = {}, namespace = action.metaNamespace): Promise<ActionResult<T, TMeta>> {
|
|
137
178
|
if (typeof options !== 'object' || options === null || Array.isArray(options)) fail('Action options must be an object');
|
|
138
179
|
const query = action.method === 'GET' || action.method === 'OPTIONS';
|
|
139
180
|
let params = encodeFields(options.params, action.input, query, 'params', action.objectInput ? action.metaNamespace : undefined);
|
|
@@ -142,20 +183,20 @@ export async function executeAction<T>(execute: RequestExecutor, action: Action,
|
|
|
142
183
|
if (objectMeta !== undefined && !isRecord(objectMeta)) fail('Object metadata must be an object');
|
|
143
184
|
const encoded = encodeFields(objectMeta, action.objectInput, query, 'params metadata');
|
|
144
185
|
if (encoded !== undefined) {
|
|
145
|
-
if (query) fail('Object metadata query input is unsupported by HaveAPI 0.
|
|
186
|
+
if (query) fail('Object metadata query input is unsupported by HaveAPI 0.32.0');
|
|
146
187
|
params = params || Object.create(null) as Record<string, unknown>;
|
|
147
|
-
params[
|
|
188
|
+
params[namespace] = encoded;
|
|
148
189
|
}
|
|
149
190
|
}
|
|
150
191
|
const meta = encodeFields(options.meta, action.globalInput, query, 'meta');
|
|
151
|
-
if (meta !== undefined && action.inputNamespace === null && params && own(params,
|
|
192
|
+
if (meta !== undefined && action.inputNamespace === null && params && own(params, namespace))
|
|
152
193
|
fail('Global metadata would overwrite root input metadata');
|
|
153
194
|
const spec: RequestSpec = {
|
|
154
195
|
method: action.method,
|
|
155
196
|
path: actionPath(action, options.path),
|
|
156
197
|
inputNamespace: action.inputNamespace,
|
|
157
198
|
outputNamespace: action.outputNamespace,
|
|
158
|
-
metaNamespace:
|
|
199
|
+
metaNamespace: namespace,
|
|
159
200
|
...(params === undefined ? {} : {params}),
|
|
160
201
|
...(meta === undefined ? {} : {meta}),
|
|
161
202
|
...(options.headers === undefined ? {} : {headers: options.headers}),
|
|
@@ -165,5 +206,38 @@ export async function executeAction<T>(execute: RequestExecutor, action: Action,
|
|
|
165
206
|
const response = await execute(spec);
|
|
166
207
|
// The sole response-type narrowing. Application payload fields are unvalidated;
|
|
167
208
|
// preserve the executor's metadata, full envelope and HTTP status receipt.
|
|
168
|
-
return {...response, data: response.data as T};
|
|
209
|
+
return {...response, data: canonicalObjectMetadata(response.data, action, namespace) as T, meta: response.meta as TMeta & Record<string, unknown>};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function canonicalObjectMetadata(data: unknown, action: Action, namespace: string): unknown {
|
|
213
|
+
if (namespace === action.metaNamespace || !action.objectOutput || !Array.isArray(data)) return data;
|
|
214
|
+
// Only declared object-list metadata positions are projected. Custom payloads,
|
|
215
|
+
// nested values and the original envelope are never traversed or modified.
|
|
216
|
+
return data.map(row => isRecord(row) && own(row, namespace) ? {...row, [action.metaNamespace]: row[namespace]} : row);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function descriptionSpec(path: string, metaNamespace: string, options: DescribeOptions): RequestSpec {
|
|
220
|
+
return {
|
|
221
|
+
method: 'OPTIONS', path, inputNamespace: null, outputNamespace: null, metaNamespace,
|
|
222
|
+
...(options.headers === undefined ? {} : {headers: options.headers}),
|
|
223
|
+
...(options.signal === undefined ? {} : {signal: options.signal}),
|
|
224
|
+
...(options.timeoutMs === undefined ? {} : {timeoutMs: options.timeoutMs}),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function checkDescribeOptions(options: DescribeOptions, action: boolean): void {
|
|
228
|
+
if (!isRecord(options)) fail('Description options must be an object');
|
|
229
|
+
for (const key of Object.keys(options)) {
|
|
230
|
+
if (!['headers', 'signal', 'timeoutMs', ...(action ? ['path'] : [])].includes(key))
|
|
231
|
+
fail(`Unknown description option ${key}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** Live OPTIONS documents use the versioned executor, without auth discovery or caching. */
|
|
235
|
+
export async function describeApi(execute: RequestExecutor, options: DescribeOptions = {}, namespace = <%= literal(@ir.fetch(:metadata_namespace)) %>): Promise<ActionResult<ApiDescription>> {
|
|
236
|
+
checkDescribeOptions(options, false);
|
|
237
|
+
return execute(descriptionSpec('/', namespace, options)) as Promise<ActionResult<ApiDescription>>;
|
|
238
|
+
}
|
|
239
|
+
export async function describeAction(execute: RequestExecutor, action: Action, options: DescribeOptions & Pick<Options, 'path'>, namespace = action.metaNamespace): Promise<ActionResult<ActionDescription>> {
|
|
240
|
+
checkDescribeOptions(options, true);
|
|
241
|
+
const path = actionPath({...action, path: action.helpPath}, options.path);
|
|
242
|
+
return execute(descriptionSpec(path, namespace, options)) as Promise<ActionResult<ActionDescription>>;
|
|
169
243
|
}
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: haveapi-typescript-client
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.32.
|
|
4
|
+
version: 0.32.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jakub Skokan
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 0.32.
|
|
18
|
+
version: 0.32.2
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: 0.32.
|
|
25
|
+
version: 0.32.2
|
|
26
26
|
executables:
|
|
27
27
|
- haveapi-typescript-client
|
|
28
28
|
extensions: []
|
|
@@ -48,6 +48,7 @@ files:
|
|
|
48
48
|
- lib/haveapi/typescript_client/version.rb
|
|
49
49
|
- template/action.ts.erb
|
|
50
50
|
- template/api.ts.erb
|
|
51
|
+
- template/descriptions.ts.erb
|
|
51
52
|
- template/index.ts.erb
|
|
52
53
|
- template/resource.ts.erb
|
|
53
54
|
- template/runtime.ts.erb
|