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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 671560335eaec7e5fba76ee4ba21e89116ae9e073d0a91b2828a2d0f951b1bab
4
- data.tar.gz: a7cb5a9cfdef8cc1b3b78e70200548d4e16ec77181e66400044f359d3fbbdfd6
3
+ metadata.gz: 93c27159dde8cece29caa28dff63ff8cf2c79d7e637bc9417fe8e7c9619365a3
4
+ data.tar.gz: 4bc3f5b3466df58c7328fd5edd41fab0aaf333990f4b2b9f4d35e7645f7f79be
5
5
  SHA512:
6
- metadata.gz: c489af5479d2dec585303ceb2a932e45bb152901666e1cdb97b9ea18bd7caf2ede1cf00a7fcf9b3ddfe9c170cf6b1843695283dff387e165736f6a10c38ba3b2
7
- data.tar.gz: aaea1e28c31789fe8ec4c17ca1ff640d7cd41a337eca458cb52cf66aac2716635a1dbc1d10c7dc7c38edadd69a169ce17e61b765861c1a201fc2a412c4ba2069
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.30.2`.
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.30.2
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/
@@ -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.30.2` for requests and
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.30.2`
106
+ Run the wire harness with a prepared published `haveapi-client@0.32.0`
107
107
  installation:
108
108
 
109
109
  ```sh
@@ -14,5 +14,5 @@ Gem::Specification.new do |spec|
14
14
  spec.bindir = 'bin'
15
15
  spec.executables = ['haveapi-typescript-client']
16
16
  spec.require_paths = ['lib']
17
- spec.add_dependency 'haveapi-client', '~> 0.32.1'
17
+ spec.add_dependency 'haveapi-client', '~> 0.32.2'
18
18
  end
@@ -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 = "// Configured API client: #{@package_name}#{"@#{@package_version}" if @package_version}."
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 << '// Request success does not establish completion of a server-side operation.' if action.fetch(:blocking)
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 + ['// Requires an initialized api and an async context.'] + body + ['']).join("\n")
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
- shape = lines.empty? ? 'Record<string, never>' : "{\n#{lines.join("\n")}\n}"
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)}: (options#{'?' unless options_required?(action)}: #{action_type(action, 'Options')}) => Promise<RequestResult<#{action_type(action, 'Output')}>>;"
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, placeholders:, auth: desc.fetch('auth', true) != false, blocking: desc['blocking'] == true,
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[createClient RequestExecutor ClientOptions JsonValue API_VERSION SCHEMA_SHA256 RequestOptions executeAction].freeze
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?
@@ -1,5 +1,5 @@
1
1
  module HaveAPI
2
2
  module TypeScriptClient
3
- VERSION = '0.32.1'.freeze
3
+ VERSION = '0.32.2'.freeze
4
4
  end
5
5
  end
@@ -1,13 +1,18 @@
1
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')) %>;
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): (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);
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 type {ClientOptions} from './runtime.js';
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
+ }
@@ -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 type {ClientOptions, JsonValue, RequestExecutor} from './runtime.js';
3
+ export {ClientInputError} from './runtime.js';
4
+ export type {ActionResult, ClientOptions, DescribeOptions, JsonValue, RequestExecutor} from './runtime.js';
5
+ export type * from './descriptions.js';
@@ -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, 'Options') %>, <%= action_type(action, 'Output') %>} from <%= literal(relative_import(@action_paths.fetch(action.fetch(:schema_path)))) %>;
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| -%>
@@ -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 TypeError(message);
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
- encoded[wire] = encodeURIComponent(String(value));
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<RequestResult<T>> {
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.30.2');
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[action.metaNamespace] = encoded;
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, action.metaNamespace))
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: action.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.1
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.1
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.1
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