@thinger-io/cli-core 0.1.1 → 0.2.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.
- package/CHANGELOG.md +93 -0
- package/README.md +37 -5
- package/lib/api.js +3 -0
- package/lib/auth.js +29 -9
- package/lib/bucket.js +3 -0
- package/lib/cli.js +15 -2
- package/lib/config.js +55 -5
- package/lib/constants.js +8 -0
- package/lib/dashboard.js +605 -0
- package/lib/docs.js +182 -0
- package/lib/errors.js +29 -4
- package/lib/mcp/catalog.js +1235 -0
- package/lib/mcp/helpers.js +1 -1
- package/lib/mcp/registry.js +72 -30
- package/lib/mcp/resource-hooks.js +145 -0
- package/lib/mcp/server.js +46 -5
- package/lib/mcp/tools-buckets.js +79 -80
- package/lib/mcp/tools-dashboards.js +123 -0
- package/lib/mcp/tools-devices.js +88 -234
- package/lib/mcp/tools-misc.js +48 -0
- package/lib/mcp/tools-plugins.js +63 -0
- package/lib/mcp/tools-products.js +32 -385
- package/lib/mcp/tools-resource.js +760 -0
- package/lib/mcp/tools-storage.js +173 -0
- package/lib/openapi.js +169 -0
- package/lib/paths.js +8 -0
- package/lib/resource.js +2 -0
- package/lib/storage.js +2 -1
- package/openapi/swagger.json +1 -0
- package/package.json +2 -1
- package/lib/mcp/tools-alarms.js +0 -507
- package/lib/mcp/tools-profile.js +0 -635
- package/lib/mcp/tools-profiles.js +0 -30
- package/lib/mcp/tools-properties.js +0 -84
- package/lib/mcp/tools-resources.js +0 -83
- package/lib/mcp/tools-tokens.js +0 -370
|
@@ -0,0 +1,1235 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { v1, v2, v3 } from '../paths.js';
|
|
3
|
+
import { inputError } from '../errors.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The resource catalog: every server collection the generic `resource_*`
|
|
7
|
+
* tools can reach, as data.
|
|
8
|
+
*
|
|
9
|
+
* Each entry says where the collection lives and which operations the
|
|
10
|
+
* tools may run on it, with the server's operationId for each (the tests
|
|
11
|
+
* check every one against the server's OpenAPI document, and
|
|
12
|
+
* `resource_describe` uses them to find the request schemas). A draft of
|
|
13
|
+
* this table comes from `scripts/catalog-draft.js`; the table itself is
|
|
14
|
+
* reviewed by hand and never discovered at run time, so a collection the
|
|
15
|
+
* server adds (an administration one, say) reaches the model only when
|
|
16
|
+
* someone decides it should.
|
|
17
|
+
*
|
|
18
|
+
* Paths are relative to the parent's item path, or to the account
|
|
19
|
+
* (`/v{N}/users/{account}`) for top-level types; `version` applies to the
|
|
20
|
+
* whole path. Verbs map to requests as follows:
|
|
21
|
+
*
|
|
22
|
+
* list GET <collection>
|
|
23
|
+
* create POST <collection> (PUT <item> when `upsert`)
|
|
24
|
+
* get GET <item>
|
|
25
|
+
* update PUT <item>
|
|
26
|
+
* patch PATCH <item> (update with `merge: true`)
|
|
27
|
+
* delete DELETE <item>
|
|
28
|
+
* clone POST <item>/clone
|
|
29
|
+
* projects PUT <item>/projects (the `projects` field of an update)
|
|
30
|
+
*
|
|
31
|
+
* A type without `id` is a single resource of its parent (get / update on
|
|
32
|
+
* the path itself).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {'list' | 'create' | 'get' | 'update' | 'patch' | 'delete' | 'clone' | 'projects'} Verb
|
|
37
|
+
*
|
|
38
|
+
* @typedef {object} Action
|
|
39
|
+
* @property {string} op Server operationId.
|
|
40
|
+
* @property {'get' | 'post' | 'put' | 'patch' | 'delete'} method
|
|
41
|
+
* @property {string} path Appended to the item (or collection) path.
|
|
42
|
+
* @property {'item' | 'collection'} [on] Default item.
|
|
43
|
+
* @property {'v1' | 'v2' | 'v3'} [version] When it differs from the type's.
|
|
44
|
+
* @property {Record<string, string>} [query] Fixed query parameters (the caller's are added).
|
|
45
|
+
* @property {string[]} [bodyFromRecord] Without a `body`, send these fields of the saved record.
|
|
46
|
+
* @property {string} description
|
|
47
|
+
*
|
|
48
|
+
* @typedef {object} ResourceType
|
|
49
|
+
* @property {string} description One line, shown in the catalog summary.
|
|
50
|
+
* @property {'v1' | 'v2' | 'v3'} version
|
|
51
|
+
* @property {string} path Collection path, relative to the parent item or the account.
|
|
52
|
+
* @property {string} [parent] Type this one hangs off.
|
|
53
|
+
* @property {string | string[]} [id] Name of the item id (or names, for ids in several segments).
|
|
54
|
+
* @property {string[]} [bodyId] Body fields `create` copies the id into, when they differ
|
|
55
|
+
* from the id names; empty when the server generates the id.
|
|
56
|
+
* @property {Partial<Record<Verb, string>>} ops Allowed verbs → server operationId.
|
|
57
|
+
* @property {boolean} [upsert] Created with PUT on the item path, so `create` needs the id.
|
|
58
|
+
* @property {string} [notes] Longer guidance returned by `resource_describe`.
|
|
59
|
+
* @property {'account' | 'server'} [scope] Server-wide types (users, hosts, domains…)
|
|
60
|
+
* live under /v{N}/, not under an account; default account.
|
|
61
|
+
* @property {Record<string, string>} [listQuery] Default query of list (the caller's filter wins).
|
|
62
|
+
* @property {boolean} [listWhole] The server answers the whole list at once, without
|
|
63
|
+
* paging or name search: resource_list pages and searches it client-side.
|
|
64
|
+
* @property {string[]} [listFields] Fields (dot paths) kept per record in lists, for
|
|
65
|
+
* collections whose records are too big to list whole.
|
|
66
|
+
* @property {{ params: Record<string, string>, minutes: number }} [listCache] Lists whose
|
|
67
|
+
* query has these params are kept that long per profile (slow, remote catalogs).
|
|
68
|
+
* @property {Record<string, Action>} [actions] Operations that are not CRUD, run with
|
|
69
|
+
* `resource_call`: verify a domain, restart a host, test an alarm rule…
|
|
70
|
+
* @property {boolean} [sdk] `resource_describe` adds the dashboard SDK reference, read from the server.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
const PROPERTY_NOTES =
|
|
74
|
+
'Properties hold any JSON `value`. Create with body {property, name?, description?, value}; update replaces the value ({value}), while update with merge: true patches it (JSON merge of objects).';
|
|
75
|
+
|
|
76
|
+
const PROFILE_NOTES =
|
|
77
|
+
'Product profile entries are keyed by name: create stores the entry under `id` (PUT), replacing what was there; update keeps the fields left out (the MCP sends the saved entry with yours over it).';
|
|
78
|
+
|
|
79
|
+
/** Where profile listeners (buckets, properties) take their data from. */
|
|
80
|
+
const LISTENER_SOURCE =
|
|
81
|
+
'data.source is one of: "resource" (poll a device resource: {resource, update: "interval"|"event", interval, magnitude: "second"|"minute"|"hour"…}), "resource_stream" ({resource_stream}), "product_stream" ({product_stream, product?}), "topic" ({topic}, MQTT), "event" ({event, filter?}). Optional: data.payload_type ("template_payload" with data.payload such as "{{payload}}", "source_payload" to keep the payload as is, "source_event", "none") and data.payload_function (a function of the product code).';
|
|
82
|
+
|
|
83
|
+
const PROFILE_API_NOTES = `${PROFILE_NOTES} An API resource is a REST endpoint added to every device of the product and dispatched by the server: {enabled, request: {data: {target, payload_type?, payload?, payload_function?, ...}}, response: {data: {payload_type, source?, payload?, payload_function?}}}. request.data.target is one of: "resource" ({resource}: a device resource), "resource_stream" ({resource_stream}; defaults to the API resource name), "product_stream" ({product, product_stream}), "property" ({property}), "function" ({function}: product code), "endpoint_call" ({endpoint}), "plugin_endpoint" ({plugin, path}), "topic" ({topic}), "none". Payload types: "template_payload" (default; data.payload is a template such as "{{payload}}"), "source_payload" (forward the body), "source_event" (the whole event), "none". response.data {payload_type: "none"} is fire-and-forget; "source_payload" returns the target's answer. Example: {"enabled": true, "request": {"data": {"target": "resource_stream", "resource_stream": "backup_complete", "payload_type": "source_payload"}}, "response": {"data": {"payload_type": "none"}}}. Called by device ({tool:device_resource_call} on a device of the product), or for the product as a whole with {tool:product_resource_call}, which needs "device_id_resolver": "<function of the product code>" in the entry to find each payload's device. A resource computed by code (target "function") also needs response {"source": "request_response", "payload_type": "source_payload"} to return the value; this is how Virtual devices get data ({tool:doc_read} id "product-code").`;
|
|
84
|
+
|
|
85
|
+
const PROFILE_BUCKET_NOTES = `${PROFILE_NOTES} A profile bucket stores, for each device of the product, the samples of a data source; the bucket itself is created on the first sample. Shape: {enabled, name, description, backend: "mongodb" (default) or "influxdb", data: {source, ...}, retention?: {period, unit: "minutes"|"hours"|"days"|"months"|"years"}, tags?: []}. ${LISTENER_SOURCE} Unless the bucket name has "{{device}}", samples get "device" and "group" tags, so one bucket holds the whole fleet. Sampling a resource needs data {"source": "resource", "resource", "update": "interval", "interval", "magnitude", "payload": "{{payload}}"} (or "payload_type": "source_payload"), which stores the value as it comes; the default template payload with no "payload" writes nothing. Deleting the entry stops recording but keeps the bucket and its data. Example: {"enabled": true, "name": "backups", "backend": "mongodb", "data": {"source": "resource_stream", "resource_stream": "backup_complete", "payload_type": "source_payload"}, "retention": {"period": 1, "unit": "years"}}.`;
|
|
86
|
+
|
|
87
|
+
const PROFILE_PROPERTY_NOTES = `${PROFILE_NOTES} A profile property writes a data source into a property of each device of the product (not to be confused with product_property, a value stored on the product itself). Shape: {enabled, name, description, data: {source, ..., patch?: true to merge into the existing value}}. ${LISTENER_SOURCE}`;
|
|
88
|
+
|
|
89
|
+
/** @type {Record<string, ResourceType>} */
|
|
90
|
+
export const CATALOG = {
|
|
91
|
+
// ─── Account resources ──────────────────────────────────────────
|
|
92
|
+
device: {
|
|
93
|
+
description: 'Devices of the account (IOTMP, MQTT, HTTP, virtual).',
|
|
94
|
+
version: 'v1',
|
|
95
|
+
path: 'devices',
|
|
96
|
+
id: 'device',
|
|
97
|
+
ops: {
|
|
98
|
+
list: 'Device_List',
|
|
99
|
+
create: 'Device_Create',
|
|
100
|
+
get: 'Device_ReadStatus',
|
|
101
|
+
update: 'Device_Update',
|
|
102
|
+
delete: 'Device_Delete',
|
|
103
|
+
clone: 'Device_Clone',
|
|
104
|
+
projects: 'Device_UpdateProjects',
|
|
105
|
+
},
|
|
106
|
+
notes: '`type` defaults to Generic (IOTMP). Generic and MQTT devices authenticate with `credentials`: when creating one without them, credentials are generated and returned once (the server stores them hashed). HTTP devices send data through {tool:device_data_send}; assign a product with update {product} ("" unassigns). Virtual devices have no hardware: every resource comes from their product\'s API (product code can compute it), and they need a `location` property ({lat, lon}) to appear on maps, or they are placed by their IP. Native geofences (7.3.4-144 and later): a `geofences` property on the device, or inherited from its asset type, group or product, holds a GeoJSON FeatureCollection ([lon, lat]; Polygon, or Point with properties.radius in meters; properties.onEnter / onExit name an endpoint or ""). Every change of the device `location` emits device_geofence_event (with geofence_id, geofence_properties, product, asset_type, asset_group): store it with a product profile bucket or flow whose source is that event. See the Geofences section of {tool:doc_read} id "concepts". A new Virtual device shows disconnected in the create answer and connects a moment later.',
|
|
107
|
+
actions: {
|
|
108
|
+
stats: {
|
|
109
|
+
op: 'Device_ReadStatistics',
|
|
110
|
+
method: 'get',
|
|
111
|
+
path: 'stats',
|
|
112
|
+
version: 'v3',
|
|
113
|
+
description:
|
|
114
|
+
'Usage over the last 30 days, per day: traffic and calls (empty when the server does not record usage).',
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
product: {
|
|
119
|
+
description:
|
|
120
|
+
'Products: shared configuration and profile (APIs, buckets, properties…) for a fleet of devices.',
|
|
121
|
+
version: 'v1',
|
|
122
|
+
path: 'products',
|
|
123
|
+
id: 'product',
|
|
124
|
+
ops: {
|
|
125
|
+
list: 'Product_List',
|
|
126
|
+
create: 'Product_Create',
|
|
127
|
+
get: 'Product_ReadConfig',
|
|
128
|
+
update: 'Product_Update',
|
|
129
|
+
delete: 'Product_Delete',
|
|
130
|
+
clone: 'Product_Clone',
|
|
131
|
+
projects: 'Product_UpdateProjects',
|
|
132
|
+
},
|
|
133
|
+
notes: 'The profile can be sent whole in create or update, or edited entry by entry through the product_profile_* types.',
|
|
134
|
+
actions: {
|
|
135
|
+
export: {
|
|
136
|
+
op: 'Product_Export',
|
|
137
|
+
method: 'get',
|
|
138
|
+
path: 'export',
|
|
139
|
+
description:
|
|
140
|
+
'The product with what it depends on, as one JSON document, to back it up or recreate it.',
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
bucket: {
|
|
145
|
+
description:
|
|
146
|
+
'Data buckets (time series). Data is read and written with {tool:bucket_data_read} / {tool:bucket_data_write}.',
|
|
147
|
+
version: 'v1',
|
|
148
|
+
path: 'buckets',
|
|
149
|
+
id: 'bucket',
|
|
150
|
+
ops: {
|
|
151
|
+
list: 'Bucket_List',
|
|
152
|
+
create: 'Bucket_Create',
|
|
153
|
+
get: 'Bucket_ReadConfig',
|
|
154
|
+
update: 'Bucket_Update',
|
|
155
|
+
delete: 'Bucket_Delete',
|
|
156
|
+
clone: 'Bucket_Clone',
|
|
157
|
+
projects: 'Bucket_UpdateProjects',
|
|
158
|
+
},
|
|
159
|
+
actions: {
|
|
160
|
+
import: {
|
|
161
|
+
op: 'Bucket_CreateImport',
|
|
162
|
+
method: 'post',
|
|
163
|
+
path: 'imports',
|
|
164
|
+
description:
|
|
165
|
+
'Load records from a .csv already in a file storage (upload it with {tool:storage_file_write}) into an existing bucket: body {source: "storage", storage, file}. Header row; ";" separated if the header has one, "," otherwise. The time column (ts, date or timestamp) takes unix MILLISECONDS (seconds are misread as ms) or ISO 8601, always taken as UTC. Other cells become numbers when they parse, text otherwise; empty and "null" are left out. Columns named like a bucket tag (config.tags) are stored as that tag: a device column in a bucket tagged `device` (product buckets are) makes the records readable per device. Writes straight to the backend (MongoDB or InfluxDB, not DynamoDB), with no rate limit and no bucket_write events; answers {"progress": N}, a percentage (100 when done; the MCP shows only the last line). The way to load history in bulk: {tool:bucket_data_write} is one sample per call and rate limited.',
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
dashboard: {
|
|
170
|
+
description:
|
|
171
|
+
'Dashboards: HTML apps on the dashboard SDK, published with {tool:dashboard_publish} (widget layouts are legacy).',
|
|
172
|
+
version: 'v1',
|
|
173
|
+
path: 'dashboards',
|
|
174
|
+
id: 'dashboard',
|
|
175
|
+
sdk: true,
|
|
176
|
+
notes: 'Build dashboards as HTML apps that use the dashboard SDK (source "url", the page at `url`): {tool:dashboard_publish} uploads the app and points the dashboard at it. Widget layouts (source "widgets") are legacy, made in the console editor: read them, do not create new ones. The SDK reference is under `sdk`; TypeScript apps get its declarations with {tool:doc_read} id "dashboard-sdk-types". A per-device, product, asset type, asset group or project dashboard is the "dashboard" property of that resource instead ({tool:dashboard_publish} with `target`).',
|
|
177
|
+
ops: {
|
|
178
|
+
list: 'Dashboard_List',
|
|
179
|
+
create: 'Dashboard_Create',
|
|
180
|
+
get: 'Dashboard_ReadConfig',
|
|
181
|
+
update: 'Dashboard_Update',
|
|
182
|
+
delete: 'Dashboard_Delete',
|
|
183
|
+
clone: 'Dashboard_Clone',
|
|
184
|
+
projects: 'Dashboard_UpdateProjects',
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
endpoint: {
|
|
188
|
+
description:
|
|
189
|
+
'Endpoints: outgoing HTTP calls, emails, notifications (run one with its `call` action).',
|
|
190
|
+
version: 'v1',
|
|
191
|
+
path: 'endpoints',
|
|
192
|
+
id: 'endpoint',
|
|
193
|
+
ops: {
|
|
194
|
+
list: 'Endpoint_List',
|
|
195
|
+
create: 'Endpoint_Create',
|
|
196
|
+
get: 'Endpoint_ReadConfig',
|
|
197
|
+
update: 'Endpoint_Update',
|
|
198
|
+
delete: 'Endpoint_Delete',
|
|
199
|
+
clone: 'Endpoint_Clone',
|
|
200
|
+
projects: 'Endpoint_UpdateProjects',
|
|
201
|
+
},
|
|
202
|
+
actions: {
|
|
203
|
+
call: {
|
|
204
|
+
op: 'Endpoint_Call',
|
|
205
|
+
method: 'post',
|
|
206
|
+
path: 'call',
|
|
207
|
+
description:
|
|
208
|
+
'Run the endpoint with `body` as its payload (its templates see it as {{payload}}); returns what it answered.',
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
notes: 'Every endpoint needs `config`, even type "virtual" (a no-op receiver for tests and alarm notifications): {"endpoint": "…", "name": "…", "type": "virtual", "config": {}}.',
|
|
212
|
+
},
|
|
213
|
+
storage: {
|
|
214
|
+
description: 'File storages (their files are handled with the storage_file_* tools).',
|
|
215
|
+
version: 'v1',
|
|
216
|
+
path: 'storages',
|
|
217
|
+
id: 'storage',
|
|
218
|
+
ops: {
|
|
219
|
+
list: 'Storage_List',
|
|
220
|
+
create: 'Storage_Create',
|
|
221
|
+
get: 'Storage_ReadConfig',
|
|
222
|
+
update: 'Storage_Update',
|
|
223
|
+
delete: 'Storage_Delete',
|
|
224
|
+
clone: 'Storage_Clone',
|
|
225
|
+
projects: 'Storage_UpdateProjects',
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
asset_type: {
|
|
229
|
+
description: 'Asset types: a category of device (e.g. "boiler"), with its own properties.',
|
|
230
|
+
version: 'v1',
|
|
231
|
+
path: 'types',
|
|
232
|
+
id: 'type',
|
|
233
|
+
ops: {
|
|
234
|
+
list: 'Type_List',
|
|
235
|
+
create: 'Type_Create',
|
|
236
|
+
get: 'Type_ReadConfig',
|
|
237
|
+
update: 'Type_Update',
|
|
238
|
+
delete: 'Type_Delete',
|
|
239
|
+
clone: 'Type_Clone',
|
|
240
|
+
projects: 'Type_UpdateProjects',
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
asset_group: {
|
|
244
|
+
description: 'Asset groups: a set of devices (e.g. a site), with its own properties.',
|
|
245
|
+
version: 'v1',
|
|
246
|
+
path: 'groups',
|
|
247
|
+
id: 'group',
|
|
248
|
+
ops: {
|
|
249
|
+
list: 'Group_List',
|
|
250
|
+
create: 'Group_Create',
|
|
251
|
+
get: 'Group_ReadConfig',
|
|
252
|
+
update: 'Group_Update',
|
|
253
|
+
delete: 'Group_Delete',
|
|
254
|
+
clone: 'Group_Clone',
|
|
255
|
+
projects: 'Group_UpdateProjects',
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
project: {
|
|
259
|
+
description: 'Projects: group resources of any type to share them or show them together.',
|
|
260
|
+
version: 'v1',
|
|
261
|
+
path: 'projects',
|
|
262
|
+
id: 'project',
|
|
263
|
+
ops: {
|
|
264
|
+
list: 'Project_List',
|
|
265
|
+
create: 'Project_Create',
|
|
266
|
+
get: 'Project_ReadConfig',
|
|
267
|
+
update: 'Project_Update',
|
|
268
|
+
delete: 'Project_Delete',
|
|
269
|
+
clone: 'Project_Clone',
|
|
270
|
+
},
|
|
271
|
+
notes: 'The project\'s own id is the short one ("plant_a"); resources name it "<owner>@<project>" ("alice@plant_a"). They join it when created with projects in the body, or through their own update: {projects: ["alice@plant_a"]}; the list replaces the previous one. List the resources of a project with filter {project: "<project>"}. Share it with other accounts through project_member (and project_role for their permissions). New projects limit bucket data to their own devices (config.limit_bucket_access, default true on 7.3.4-135 and later): reads and tag lists of a bucket through the project return only its devices\' records, so such buckets need a device tag. Turn it on for older projects with update {config: {limit_bucket_access: true}} before sharing them with members or project-scoped tokens.',
|
|
272
|
+
},
|
|
273
|
+
token: {
|
|
274
|
+
description: 'Access tokens of the account, with their permission tree.',
|
|
275
|
+
version: 'v1',
|
|
276
|
+
path: 'tokens',
|
|
277
|
+
id: 'token',
|
|
278
|
+
ops: {
|
|
279
|
+
list: 'Token_List',
|
|
280
|
+
create: 'Token_Create',
|
|
281
|
+
get: 'Token_ReadConfig',
|
|
282
|
+
update: 'Token_Update',
|
|
283
|
+
delete: 'Token_Delete',
|
|
284
|
+
clone: 'Token_Clone',
|
|
285
|
+
projects: 'Token_UpdateProjects',
|
|
286
|
+
},
|
|
287
|
+
notes: 'Permissions: `allow` (and optional `deny`) is {ResourceType: {id or "*": [actions] or "*"}}, e.g. {"Device": {"*": "*"}} or {"Device": {"sensor_1": ["ReadDeviceStatus", "AccessDeviceResources"]}}; {"*": {"*": "*"}} is full access. {tool:resource_describe} of any type lists its ResourceType and actions under `permissions`. The JWT (`access_token`) is returned by create and get, never by list. `expire` (unix ms) makes a token expire: it is signed into the JWT, so it cannot be changed on update (delete the token and create it again), and the server deletes the token afterwards. Without it a token does not expire: delete it to revoke it. Servers before 7.3.4-98 ignore `expire`.',
|
|
288
|
+
},
|
|
289
|
+
alarm_rule: {
|
|
290
|
+
description: 'Alarm rules: conditions on device or bucket data that raise alarm instances.',
|
|
291
|
+
version: 'v1',
|
|
292
|
+
path: 'alarms/rules',
|
|
293
|
+
id: 'rule',
|
|
294
|
+
ops: {
|
|
295
|
+
list: 'Alarm_ListRules',
|
|
296
|
+
create: 'Alarm_CreateRule',
|
|
297
|
+
get: 'Alarm_ReadRuleConfig',
|
|
298
|
+
update: 'Alarm_UpdateRule',
|
|
299
|
+
delete: 'Alarm_DeleteRule',
|
|
300
|
+
clone: 'Alarm_CloneRule',
|
|
301
|
+
projects: 'Alarm_UpdateRuleProjects',
|
|
302
|
+
},
|
|
303
|
+
notes: 'Condition values are strings ("30", not 30: a number never matches) and only enabled devices are evaluated. A device_property source projects fields of the property document, so its fields are `value.<field>` (fields ["value.level"], condition field "battery.value.level"; "level" alone yields null and every device is skipped). The document also has `modified` and `created` (unix ms, UTC): fields ["modified"] with condition field `ts() - battery.modified` above "3600000" flags a property not updated for an hour. A condition value can name a field of another source of the same device ("limits.value.max_depth_m"): per-device thresholds, inherited from asset type, group or product; sources, conditions and confirmations are in {tool:doc_read} id "concepts" (Alarms). Try a rule before saving it with its test_rule / test_source / test_notification actions ({tool:resource_call}).',
|
|
304
|
+
actions: {
|
|
305
|
+
test_rule: {
|
|
306
|
+
op: 'Alarm_TestRuleTest-rule',
|
|
307
|
+
method: 'post',
|
|
308
|
+
path: 'test-rule',
|
|
309
|
+
bodyFromRecord: ['name', 'description', 'enabled', 'config'],
|
|
310
|
+
description:
|
|
311
|
+
'Evaluate a whole rule without raising alarms or notifying: `body` is the rule as it would be saved ({name, enabled, config}), and the rule id may not exist yet. Without `body`, it tests the saved rule.',
|
|
312
|
+
},
|
|
313
|
+
test_source: {
|
|
314
|
+
op: 'Alarm_TestRuleSourceTest-source',
|
|
315
|
+
method: 'post',
|
|
316
|
+
path: 'test-source',
|
|
317
|
+
description:
|
|
318
|
+
'Read one data source as the rule would (the rule id need not exist; the source comes in the body): `body` is {name, source: "bucket" | "device_property" | "device_status", <source>: {…}}, e.g. {name: "loc", source: "device_property", device_property: {device: "d1", property: "location"}}. A bucket source without aggregation reads the window of `check_interval` (as in config.check_interval; default the saved rule\'s, or 1 minute).',
|
|
319
|
+
},
|
|
320
|
+
test_notification: {
|
|
321
|
+
op: 'Alarm_TestRuleNotificationTest-notification',
|
|
322
|
+
method: 'post',
|
|
323
|
+
path: 'test-notification',
|
|
324
|
+
description:
|
|
325
|
+
'Send the rule notification for real, through its endpoints, with `body` as the event data: people get it. Confirm with the user first.',
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
alarm_instance: {
|
|
330
|
+
description:
|
|
331
|
+
'Alarm instances raised by the rules; update them to acknowledge, shelve or clear.',
|
|
332
|
+
version: 'v1',
|
|
333
|
+
path: 'alarms/instances',
|
|
334
|
+
id: 'instance',
|
|
335
|
+
ops: {
|
|
336
|
+
list: 'Alarm_ListInstances',
|
|
337
|
+
get: 'Alarm_ReadInstanceConfig',
|
|
338
|
+
update: 'Alarm_UpdateInstance',
|
|
339
|
+
delete: 'Alarm_DeleteInstance',
|
|
340
|
+
projects: 'Alarm_UpdateInstanceProjects',
|
|
341
|
+
},
|
|
342
|
+
notes: 'States: 0 none, 1 active, 2 acknowledged, 3 latched, 4 shelved, 5 cleared; severities: 0 none, 1 low, 2 medium, 3 high, 4 critical. Acknowledge with update {state: 2, annotation?}; shelve with {state: 4, reactivation: {type: "timespan", timespan: {value, magnitude: "minute"|"hour"|"day"…}}}; clear with {state: 5} (a cleared alarm cannot be reactivated). Prefer clearing to deleting: delete removes the history. List filters: state and severity (arrays allowed), name, sort, order, and exact values of nested fields with dots: {"alarm.rule": "<rule id>"}, {"origin.id": "<device>"}, or both (servers before this filter ignore it and list every instance: check the records). `search` matches the instance id and the rule\'s name, not the rule id. A record: {instance, name, description, state, severity, alarm: {rule: <rule id>, group, type}, origin: {source: "device", id: <device>, name}, asset_type, asset_group, activation: {initiated, completed, detections}, normalization: {…}, evaluation: {last_values: {<source>: {<field>: value}}, last_completed}, notification: {last_type, total_completed, total_failed}, project}; times in unix ms.',
|
|
343
|
+
},
|
|
344
|
+
claim: {
|
|
345
|
+
description: 'Device claims: codes that let end users take ownership of a device.',
|
|
346
|
+
version: 'v1',
|
|
347
|
+
path: 'claims',
|
|
348
|
+
id: 'claim',
|
|
349
|
+
ops: {
|
|
350
|
+
list: 'Claim_List',
|
|
351
|
+
create: 'Claim_Create',
|
|
352
|
+
get: 'Claim_ReadConfig',
|
|
353
|
+
update: 'Claim_Update',
|
|
354
|
+
delete: 'Claim_Delete',
|
|
355
|
+
clone: 'Claim_Clone',
|
|
356
|
+
projects: 'Claim_UpdateProjects',
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
property: {
|
|
360
|
+
description: 'Account properties: JSON values stored on the account.',
|
|
361
|
+
version: 'v1',
|
|
362
|
+
path: 'properties',
|
|
363
|
+
id: 'property',
|
|
364
|
+
ops: {
|
|
365
|
+
list: 'Property_ListProperties',
|
|
366
|
+
create: 'Property_Create',
|
|
367
|
+
get: 'Property_Read',
|
|
368
|
+
update: 'Property_Update',
|
|
369
|
+
patch: 'Property_Patch',
|
|
370
|
+
delete: 'Property_Delete',
|
|
371
|
+
clone: 'Property_Clone',
|
|
372
|
+
},
|
|
373
|
+
notes: PROPERTY_NOTES,
|
|
374
|
+
},
|
|
375
|
+
role: {
|
|
376
|
+
description: 'Roles: named permission sets for project members.',
|
|
377
|
+
version: 'v1',
|
|
378
|
+
path: 'roles',
|
|
379
|
+
id: 'role',
|
|
380
|
+
notes: 'Account roles: named permission sets ({allow, deny} trees as in tokens) that project members get through `global_roles`.',
|
|
381
|
+
ops: {
|
|
382
|
+
list: 'Role_List',
|
|
383
|
+
create: 'Role_Create',
|
|
384
|
+
get: 'Role_ReadConfig',
|
|
385
|
+
update: 'Role_Update',
|
|
386
|
+
delete: 'Role_Delete',
|
|
387
|
+
clone: 'Role_Clone',
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
sync: {
|
|
391
|
+
description:
|
|
392
|
+
'Syncs: shared semaphores with a number of slots that devices or scripts take (locks) to coordinate.',
|
|
393
|
+
version: 'v1',
|
|
394
|
+
path: 'syncs',
|
|
395
|
+
id: 'sync',
|
|
396
|
+
notes: 'config: {available: slots, timeout: default lock lifetime in seconds (0 = until released)}. Take slots by creating a sync_lock, release them by deleting it. E.g. {available: 1} lets one controller at a time use a pump.',
|
|
397
|
+
ops: {
|
|
398
|
+
list: 'Sync_List',
|
|
399
|
+
create: 'Sync_Create',
|
|
400
|
+
get: 'Sync_ReadConfig',
|
|
401
|
+
update: 'Sync_Update',
|
|
402
|
+
delete: 'Sync_Delete',
|
|
403
|
+
clone: 'Sync_Clone',
|
|
404
|
+
projects: 'Sync_UpdateProjects',
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
|
|
408
|
+
plugin: {
|
|
409
|
+
description:
|
|
410
|
+
'Plugins installed on the account: containers that add features (integrations, apps); their API is reached with plugin_call.',
|
|
411
|
+
version: 'v1',
|
|
412
|
+
path: 'plugins',
|
|
413
|
+
id: 'plugin',
|
|
414
|
+
ops: { list: 'Plugin_List', get: 'Plugin_ReadInfo' },
|
|
415
|
+
listQuery: { filter: 'installed' },
|
|
416
|
+
listWhole: true,
|
|
417
|
+
listCache: { params: { filter: 'available' }, minutes: 15 },
|
|
418
|
+
listFields: [
|
|
419
|
+
'name',
|
|
420
|
+
'plugin',
|
|
421
|
+
'version',
|
|
422
|
+
'description',
|
|
423
|
+
'author',
|
|
424
|
+
'deprecated',
|
|
425
|
+
'metadata.name',
|
|
426
|
+
'metadata.description',
|
|
427
|
+
'metadata.category',
|
|
428
|
+
'metadata.vendor',
|
|
429
|
+
'state',
|
|
430
|
+
'status',
|
|
431
|
+
'enabled',
|
|
432
|
+
],
|
|
433
|
+
notes: 'resource_list shows the installed plugins; with filter {filter: "available"} it lists the catalog of plugins that can be installed, summarised and paged (it is long; kept 15 minutes, so search it freely): `search` matches words against name, description, category and vendor ("lorawan milesight"). Install, upgrade, start, stop… with {tool:resource_call} actions. A plugin is usually configured through its properties (type plugin_property). Its logs and terminal are only available in the console. Install and upgrade leave the container created but stopped: run the start action afterwards. resource_get of a plugin that is not installed answers with its catalog manifest, without `state`: its resources.products[].profile are complete product profiles for real hardware (api, buckets, properties, flows, autoprovisions, code) to learn from or reuse. The manifest comes without the source of the code and the console dashboard (_resources), which are large: pass full: true to resource_get to include them (the decoder of a LoRaWAN product, for instance); remove also deletes its files, volumes and properties.',
|
|
434
|
+
actions: {
|
|
435
|
+
install: {
|
|
436
|
+
op: 'Plugin_ManageState',
|
|
437
|
+
method: 'post',
|
|
438
|
+
path: '',
|
|
439
|
+
query: { action: 'create' },
|
|
440
|
+
description:
|
|
441
|
+
'Install the plugin `id` (its name in the plugin catalog); `body` holds its settings, query {version} picks one (default main). The container is created stopped: start it next.',
|
|
442
|
+
},
|
|
443
|
+
upgrade: {
|
|
444
|
+
op: 'Plugin_ManageState',
|
|
445
|
+
method: 'post',
|
|
446
|
+
path: '',
|
|
447
|
+
query: { action: 'upgrade' },
|
|
448
|
+
description:
|
|
449
|
+
'Upgrade the plugin to query {version} (default master); `body` holds its settings. The container is recreated stopped (volumes kept): start it next.',
|
|
450
|
+
},
|
|
451
|
+
remove: {
|
|
452
|
+
op: 'Plugin_ManageState',
|
|
453
|
+
method: 'post',
|
|
454
|
+
path: '',
|
|
455
|
+
query: { action: 'remove' },
|
|
456
|
+
description:
|
|
457
|
+
'Uninstall the plugin: removes its container, files, volumes (its data) and properties. Confirm with the user first.',
|
|
458
|
+
},
|
|
459
|
+
start: {
|
|
460
|
+
op: 'Plugin_ManageState',
|
|
461
|
+
method: 'post',
|
|
462
|
+
path: '',
|
|
463
|
+
query: { action: 'start' },
|
|
464
|
+
description: 'Start the plugin container.',
|
|
465
|
+
},
|
|
466
|
+
stop: {
|
|
467
|
+
op: 'Plugin_ManageState',
|
|
468
|
+
method: 'post',
|
|
469
|
+
path: '',
|
|
470
|
+
query: { action: 'stop' },
|
|
471
|
+
description: 'Stop the plugin container.',
|
|
472
|
+
},
|
|
473
|
+
restart: {
|
|
474
|
+
op: 'Plugin_ManageState',
|
|
475
|
+
method: 'post',
|
|
476
|
+
path: '',
|
|
477
|
+
query: { action: 'restart' },
|
|
478
|
+
description: 'Restart the plugin container.',
|
|
479
|
+
},
|
|
480
|
+
pause: {
|
|
481
|
+
op: 'Plugin_ManageState',
|
|
482
|
+
method: 'post',
|
|
483
|
+
path: '',
|
|
484
|
+
query: { action: 'pause' },
|
|
485
|
+
description: 'Pause the plugin container.',
|
|
486
|
+
},
|
|
487
|
+
unpause: {
|
|
488
|
+
op: 'Plugin_ManageState',
|
|
489
|
+
method: 'post',
|
|
490
|
+
path: '',
|
|
491
|
+
query: { action: 'unpause' },
|
|
492
|
+
description: 'Resume a paused plugin container.',
|
|
493
|
+
},
|
|
494
|
+
kill: {
|
|
495
|
+
op: 'Plugin_ManageState',
|
|
496
|
+
method: 'post',
|
|
497
|
+
path: '',
|
|
498
|
+
query: { action: 'kill' },
|
|
499
|
+
description: 'Kill the plugin container. Confirm with the user first.',
|
|
500
|
+
},
|
|
501
|
+
public: {
|
|
502
|
+
op: 'Plugin_ManageState',
|
|
503
|
+
method: 'post',
|
|
504
|
+
path: '',
|
|
505
|
+
query: { action: 'public' },
|
|
506
|
+
description: "Make the plugin's web interface reachable without signing in.",
|
|
507
|
+
},
|
|
508
|
+
private: {
|
|
509
|
+
op: 'Plugin_ManageState',
|
|
510
|
+
method: 'post',
|
|
511
|
+
path: '',
|
|
512
|
+
query: { action: 'private' },
|
|
513
|
+
description: "Make the plugin's web interface require signing in again.",
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
// ─── Server administration ──────────────────────────────────────
|
|
518
|
+
// Server-wide (not under an account): only server administrators may
|
|
519
|
+
// use them, and the server says so when the token may not.
|
|
520
|
+
user: {
|
|
521
|
+
description: 'User accounts of the server.',
|
|
522
|
+
version: 'v1',
|
|
523
|
+
scope: 'server',
|
|
524
|
+
path: 'users',
|
|
525
|
+
id: 'user',
|
|
526
|
+
ops: {
|
|
527
|
+
list: 'User_List',
|
|
528
|
+
create: 'User_Create',
|
|
529
|
+
get: 'User_ReadConfig',
|
|
530
|
+
update: 'User_Update',
|
|
531
|
+
delete: 'User_Delete',
|
|
532
|
+
},
|
|
533
|
+
notes: 'Creating a user sets its password: ask the user for it, never invent one. Deleting a user deletes everything the account owns.',
|
|
534
|
+
},
|
|
535
|
+
host: {
|
|
536
|
+
description: 'Hosts of the server cluster.',
|
|
537
|
+
version: 'v1',
|
|
538
|
+
scope: 'server',
|
|
539
|
+
path: 'hosts',
|
|
540
|
+
id: 'host',
|
|
541
|
+
ops: {
|
|
542
|
+
list: 'Host_List',
|
|
543
|
+
create: 'Host_Create',
|
|
544
|
+
get: 'Host_ReadConfig',
|
|
545
|
+
update: 'Host_Update',
|
|
546
|
+
delete: 'Host_Delete',
|
|
547
|
+
},
|
|
548
|
+
notes: 'update_settings is a JSON merge over the host\'s custom settings and only takes effect after the restart action, which drops every connection: plan both with the user. Ports are strings. Read {tool:doc_read} id "server-admin" first. read_settings leaves out some keys (limits of accounts, brands, domains and members, database, plugins, rate limiter); {tool:resource_get} of the host shows the effective ones in current_settings. On cloud instances (license "cloud" in /v1/server/version) the subscription manages those limits, database, plugins, rate limiter and http_server.hosts: changing them answers 403 naming the path; tell the user it depends on their plan.',
|
|
549
|
+
actions: {
|
|
550
|
+
logs: {
|
|
551
|
+
op: 'Host_ReadLogs',
|
|
552
|
+
method: 'get',
|
|
553
|
+
path: 'logs',
|
|
554
|
+
description:
|
|
555
|
+
'The last lines of the host log, as text. query: {lines: 200, timestamps: true, since: …}. Reads the server container named in deployment.container_name (default "thinger"): not found means it has another name.',
|
|
556
|
+
},
|
|
557
|
+
restart: {
|
|
558
|
+
op: 'Host_UpdateStateRestart',
|
|
559
|
+
method: 'post',
|
|
560
|
+
path: 'restart',
|
|
561
|
+
description:
|
|
562
|
+
'Restart the host: its devices and users lose the connection meanwhile. Confirm with the user first.',
|
|
563
|
+
},
|
|
564
|
+
read_settings: {
|
|
565
|
+
op: 'Host_ReadSettings',
|
|
566
|
+
method: 'get',
|
|
567
|
+
path: 'settings',
|
|
568
|
+
description: 'Read the host settings.',
|
|
569
|
+
},
|
|
570
|
+
update_settings: {
|
|
571
|
+
op: 'Host_UpdateSettings',
|
|
572
|
+
method: 'put',
|
|
573
|
+
path: 'settings',
|
|
574
|
+
description:
|
|
575
|
+
'Replace the host settings with `body`; read them first. Confirm with the user first.',
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
},
|
|
579
|
+
domain: {
|
|
580
|
+
description: 'Custom domains the server answers on.',
|
|
581
|
+
version: 'v1',
|
|
582
|
+
scope: 'server',
|
|
583
|
+
path: 'domains',
|
|
584
|
+
id: 'domain',
|
|
585
|
+
ops: {
|
|
586
|
+
list: 'Domain_List',
|
|
587
|
+
create: 'Domain_Create',
|
|
588
|
+
get: 'Domain_ReadConfig',
|
|
589
|
+
update: 'Domain_Update',
|
|
590
|
+
delete: 'Domain_Delete',
|
|
591
|
+
},
|
|
592
|
+
actions: {
|
|
593
|
+
verify: {
|
|
594
|
+
op: 'Domain_Verify',
|
|
595
|
+
method: 'post',
|
|
596
|
+
path: 'verify',
|
|
597
|
+
description:
|
|
598
|
+
'Check that the domain resolves to this server (after the user configures its DNS).',
|
|
599
|
+
},
|
|
600
|
+
},
|
|
601
|
+
notes: 'A domain is usable once it resolves to this server (an A/AAAA record with its IP, or a CNAME to its current hostname) with port 80 reachable, and the verify action succeeds (it gets the certificate; a failure says why: DNS, invalid suffix…). Ask the user to set up the DNS record, then verify. A brand with the same domain then gives it its look: {tool:doc_read} id "server-admin" has the whole flow.',
|
|
602
|
+
},
|
|
603
|
+
brand: {
|
|
604
|
+
description: 'Brands: the look (and email) of the console on a domain.',
|
|
605
|
+
version: 'v1',
|
|
606
|
+
scope: 'server',
|
|
607
|
+
path: 'brands',
|
|
608
|
+
id: 'brand',
|
|
609
|
+
notes: 'Change a brand with {tool:resource_update} and merge: true (JSON merge of config and email; null removes a key; logos are kept). Without merge the whole brand is replaced and logos missing from it are deleted: only send complete objects. Logos and images go as data URLs (png or webp); to keep one in a full update, send back its /brand/images/… URL. The email settings are the brand\'s `email` field; test them with the test_email action (sent to the admin calling). For the whole domain + brand setup read {tool:doc_read} id "server-admin". Reading a brand that does not exist returns defaults (only config.accounts), not an error: a real brand has the `brand` field.',
|
|
610
|
+
ops: {
|
|
611
|
+
list: 'Brand_List',
|
|
612
|
+
create: 'Brand_Create',
|
|
613
|
+
get: 'Brand_ReadConfig',
|
|
614
|
+
update: 'Brand_Update',
|
|
615
|
+
patch: 'Brand_Patch',
|
|
616
|
+
delete: 'Brand_Delete',
|
|
617
|
+
},
|
|
618
|
+
actions: {
|
|
619
|
+
test_email: {
|
|
620
|
+
op: 'Brand_TestEmail',
|
|
621
|
+
method: 'post',
|
|
622
|
+
path: 'email/test',
|
|
623
|
+
description: "Send a test email with the brand's email settings.",
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
oauth_client: {
|
|
628
|
+
description: 'OAuth clients: applications allowed to sign users in against this server.',
|
|
629
|
+
version: 'v1',
|
|
630
|
+
scope: 'server',
|
|
631
|
+
path: 'oauth_clients',
|
|
632
|
+
id: 'oauth_client',
|
|
633
|
+
notes: 'Its `secret` is a credential: choose a random one when creating it, and do not repeat it in answers; get returns it.',
|
|
634
|
+
ops: {
|
|
635
|
+
list: 'OauthClient_List',
|
|
636
|
+
create: 'OauthClient_Create',
|
|
637
|
+
get: 'OauthClient_ReadConfig',
|
|
638
|
+
update: 'OauthClient_Update',
|
|
639
|
+
delete: 'OauthClient_Delete',
|
|
640
|
+
},
|
|
641
|
+
},
|
|
642
|
+
identity_provider: {
|
|
643
|
+
description:
|
|
644
|
+
'Identity providers: external sign-in (OpenID Connect, OAuth) for the console.',
|
|
645
|
+
version: 'v1',
|
|
646
|
+
scope: 'server',
|
|
647
|
+
path: 'identity_providers',
|
|
648
|
+
id: 'identity_provider',
|
|
649
|
+
ops: {
|
|
650
|
+
list: 'IdentityProvider_List',
|
|
651
|
+
create: 'IdentityProvider_Create',
|
|
652
|
+
get: 'IdentityProvider_ReadConfig',
|
|
653
|
+
update: 'IdentityProvider_Update',
|
|
654
|
+
delete: 'IdentityProvider_Delete',
|
|
655
|
+
},
|
|
656
|
+
notes: 'Client secrets belong to the user: ask for them, never invent or echo them back. The server allows them only when accounts.limits.identity_providers.max_count is raised (host settings, then restart). See {tool:doc_read} id "server-admin".',
|
|
657
|
+
},
|
|
658
|
+
proxy: {
|
|
659
|
+
description: 'Proxies: services the server exposes through its own address.',
|
|
660
|
+
version: 'v1',
|
|
661
|
+
scope: 'server',
|
|
662
|
+
path: 'proxies',
|
|
663
|
+
id: 'proxy',
|
|
664
|
+
ops: {
|
|
665
|
+
list: 'Proxy_ListProxies',
|
|
666
|
+
create: 'Proxy_Create',
|
|
667
|
+
get: 'Proxy_ReadConfig',
|
|
668
|
+
update: 'Proxy_Update',
|
|
669
|
+
delete: 'Proxy_Delete',
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
|
|
673
|
+
sync_lock: {
|
|
674
|
+
description: 'Locks of a sync: slots currently taken.',
|
|
675
|
+
version: 'v1',
|
|
676
|
+
parent: 'sync',
|
|
677
|
+
path: 'locks',
|
|
678
|
+
id: 'lock',
|
|
679
|
+
ops: {
|
|
680
|
+
list: 'Sync_ListLocks',
|
|
681
|
+
create: 'Sync_CreateLock',
|
|
682
|
+
get: 'Sync_ReadLockConfig',
|
|
683
|
+
update: 'Sync_UpdateLock',
|
|
684
|
+
delete: 'Sync_DeleteLock',
|
|
685
|
+
},
|
|
686
|
+
notes: 'Create takes `slots` (default 1) and fails when not enough are free; `timeout` (seconds) overrides the sync default; without an id a random one is assigned. Delete the lock to release its slots.',
|
|
687
|
+
},
|
|
688
|
+
|
|
689
|
+
// ─── Device children ────────────────────────────────────────────
|
|
690
|
+
device_property: {
|
|
691
|
+
description: 'Properties of a device: its structured state (configuration, calibration…).',
|
|
692
|
+
version: 'v3',
|
|
693
|
+
parent: 'device',
|
|
694
|
+
path: 'properties',
|
|
695
|
+
id: 'property',
|
|
696
|
+
ops: {
|
|
697
|
+
list: 'Device_ListProperties',
|
|
698
|
+
create: 'Device_CreateProperty',
|
|
699
|
+
get: 'Device_ReadProperty',
|
|
700
|
+
update: 'Device_UpdateProperty',
|
|
701
|
+
patch: 'Device_PatchProperty',
|
|
702
|
+
delete: 'Device_DeleteProperty',
|
|
703
|
+
clone: 'Device_CloneProperty',
|
|
704
|
+
},
|
|
705
|
+
notes: PROPERTY_NOTES,
|
|
706
|
+
},
|
|
707
|
+
device_token: {
|
|
708
|
+
description: 'Tokens scoped to one device and some of its resources.',
|
|
709
|
+
version: 'v1',
|
|
710
|
+
parent: 'device',
|
|
711
|
+
path: 'tokens',
|
|
712
|
+
id: 'token',
|
|
713
|
+
bodyId: [],
|
|
714
|
+
ops: {
|
|
715
|
+
list: 'Device_ListTokens',
|
|
716
|
+
create: 'Device_CreateToken',
|
|
717
|
+
delete: 'Device_DeleteToken',
|
|
718
|
+
},
|
|
719
|
+
notes: 'Create with {token_name, token_resources?: [resource names] (all when omitted), token_expiration?: unix seconds or a duration such as "30d"}.',
|
|
720
|
+
},
|
|
721
|
+
device_bucket: {
|
|
722
|
+
description: 'Buckets that hold data of a device (read-only listing).',
|
|
723
|
+
version: 'v3',
|
|
724
|
+
parent: 'device',
|
|
725
|
+
path: 'buckets',
|
|
726
|
+
id: 'bucket',
|
|
727
|
+
ops: { list: 'Device_ListBuckets' },
|
|
728
|
+
},
|
|
729
|
+
device_bucket_tag: {
|
|
730
|
+
description: 'Tags of a device bucket (parent: "<device>/<bucket>").',
|
|
731
|
+
version: 'v3',
|
|
732
|
+
parent: 'device_bucket',
|
|
733
|
+
path: 'tags',
|
|
734
|
+
id: 'tag',
|
|
735
|
+
ops: { list: 'Device_ListBucketTags', get: 'Device_ReadBucketTag' },
|
|
736
|
+
},
|
|
737
|
+
device_service: {
|
|
738
|
+
description: 'Services a device exposes (web, VNC…).',
|
|
739
|
+
version: 'v3',
|
|
740
|
+
parent: 'device',
|
|
741
|
+
path: 'services',
|
|
742
|
+
id: 'service',
|
|
743
|
+
ops: { list: 'Device_ListServices', get: 'Device_ReadService' },
|
|
744
|
+
},
|
|
745
|
+
device_callback: {
|
|
746
|
+
description:
|
|
747
|
+
'HTTP callback settings of a device: what the server does with the data it receives.',
|
|
748
|
+
version: 'v3',
|
|
749
|
+
parent: 'device',
|
|
750
|
+
path: 'callback',
|
|
751
|
+
ops: { get: 'Device_ReadCallbackConfig', update: 'Device_UpdateCallback' },
|
|
752
|
+
},
|
|
753
|
+
|
|
754
|
+
// ─── Product children ───────────────────────────────────────────
|
|
755
|
+
product_property: {
|
|
756
|
+
description: 'Properties of a product.',
|
|
757
|
+
version: 'v1',
|
|
758
|
+
parent: 'product',
|
|
759
|
+
path: 'properties',
|
|
760
|
+
id: 'property',
|
|
761
|
+
ops: {
|
|
762
|
+
list: 'Product_ListProperties',
|
|
763
|
+
create: 'Product_CreateProperty',
|
|
764
|
+
get: 'Product_ReadProperty',
|
|
765
|
+
update: 'Product_UpdateProperty',
|
|
766
|
+
patch: 'Product_PatchProperty',
|
|
767
|
+
delete: 'Product_DeleteProperty',
|
|
768
|
+
clone: 'Product_CloneProperty',
|
|
769
|
+
},
|
|
770
|
+
notes: PROPERTY_NOTES,
|
|
771
|
+
},
|
|
772
|
+
product_profile_api: {
|
|
773
|
+
description:
|
|
774
|
+
'API resources a product adds to its devices (calls to device resources, endpoints, scripts).',
|
|
775
|
+
version: 'v1',
|
|
776
|
+
parent: 'product',
|
|
777
|
+
path: 'profile/api',
|
|
778
|
+
id: 'resource',
|
|
779
|
+
upsert: true,
|
|
780
|
+
ops: {
|
|
781
|
+
list: 'Product_ReadProfile',
|
|
782
|
+
create: 'Product_UpdateProductProfileResource',
|
|
783
|
+
get: 'Product_ReadProductProfileResource',
|
|
784
|
+
update: 'Product_UpdateProductProfileResource',
|
|
785
|
+
delete: 'Product_DeleteProductProfileResource',
|
|
786
|
+
},
|
|
787
|
+
notes: PROFILE_API_NOTES,
|
|
788
|
+
actions: {
|
|
789
|
+
replace_all: {
|
|
790
|
+
op: 'Product_UpdateProfile',
|
|
791
|
+
method: 'put',
|
|
792
|
+
path: '',
|
|
793
|
+
on: 'collection',
|
|
794
|
+
description:
|
|
795
|
+
'Replace every entry of this profile category at once with `body` ({id: entry, …}). The only way to use ids with placeholders such as {{device}}_data, which the per-entry path does not accept.',
|
|
796
|
+
},
|
|
797
|
+
},
|
|
798
|
+
},
|
|
799
|
+
product_profile_bucket: {
|
|
800
|
+
description: 'Buckets a product keeps for each of its devices.',
|
|
801
|
+
version: 'v1',
|
|
802
|
+
parent: 'product',
|
|
803
|
+
path: 'profile/buckets',
|
|
804
|
+
id: 'resource',
|
|
805
|
+
upsert: true,
|
|
806
|
+
ops: {
|
|
807
|
+
list: 'Product_ReadProfile',
|
|
808
|
+
create: 'Product_UpdateProductProfileResource',
|
|
809
|
+
get: 'Product_ReadProductProfileResource',
|
|
810
|
+
update: 'Product_UpdateProductProfileResource',
|
|
811
|
+
delete: 'Product_DeleteProductProfileResource',
|
|
812
|
+
},
|
|
813
|
+
notes: PROFILE_BUCKET_NOTES,
|
|
814
|
+
actions: {
|
|
815
|
+
replace_all: {
|
|
816
|
+
op: 'Product_UpdateProfile',
|
|
817
|
+
method: 'put',
|
|
818
|
+
path: '',
|
|
819
|
+
on: 'collection',
|
|
820
|
+
description:
|
|
821
|
+
'Replace every entry of this profile category at once with `body` ({id: entry, …}). The only way to use ids with placeholders such as {{device}}_data, which the per-entry path does not accept.',
|
|
822
|
+
},
|
|
823
|
+
},
|
|
824
|
+
},
|
|
825
|
+
product_profile_property: {
|
|
826
|
+
description: 'Properties a product creates on each of its devices.',
|
|
827
|
+
version: 'v1',
|
|
828
|
+
parent: 'product',
|
|
829
|
+
path: 'profile/properties',
|
|
830
|
+
id: 'resource',
|
|
831
|
+
upsert: true,
|
|
832
|
+
ops: {
|
|
833
|
+
list: 'Product_ReadProfile',
|
|
834
|
+
create: 'Product_UpdateProductProfileResource',
|
|
835
|
+
get: 'Product_ReadProductProfileResource',
|
|
836
|
+
update: 'Product_UpdateProductProfileResource',
|
|
837
|
+
delete: 'Product_DeleteProductProfileResource',
|
|
838
|
+
},
|
|
839
|
+
notes: PROFILE_PROPERTY_NOTES,
|
|
840
|
+
actions: {
|
|
841
|
+
replace_all: {
|
|
842
|
+
op: 'Product_UpdateProfile',
|
|
843
|
+
method: 'put',
|
|
844
|
+
path: '',
|
|
845
|
+
on: 'collection',
|
|
846
|
+
description:
|
|
847
|
+
'Replace every entry of this profile category at once with `body` ({id: entry, …}). The only way to use ids with placeholders such as {{device}}_data, which the per-entry path does not accept.',
|
|
848
|
+
},
|
|
849
|
+
},
|
|
850
|
+
},
|
|
851
|
+
product_profile_autoprovision: {
|
|
852
|
+
description: 'Autoprovisioning rules: which connecting devices join the product.',
|
|
853
|
+
version: 'v1',
|
|
854
|
+
parent: 'product',
|
|
855
|
+
path: 'profile/autoprovisions',
|
|
856
|
+
id: 'resource',
|
|
857
|
+
upsert: true,
|
|
858
|
+
ops: {
|
|
859
|
+
list: 'Product_ReadProfile',
|
|
860
|
+
create: 'Product_UpdateProductProfileResource',
|
|
861
|
+
get: 'Product_ReadProductProfileResource',
|
|
862
|
+
update: 'Product_UpdateProductProfileResource',
|
|
863
|
+
delete: 'Product_DeleteProductProfileResource',
|
|
864
|
+
},
|
|
865
|
+
notes: PROFILE_NOTES,
|
|
866
|
+
actions: {
|
|
867
|
+
replace_all: {
|
|
868
|
+
op: 'Product_UpdateProfile',
|
|
869
|
+
method: 'put',
|
|
870
|
+
path: '',
|
|
871
|
+
on: 'collection',
|
|
872
|
+
description:
|
|
873
|
+
'Replace every entry of this profile category at once with `body` ({id: entry, …}). The only way to use ids with placeholders such as {{device}}_data, which the per-entry path does not accept.',
|
|
874
|
+
},
|
|
875
|
+
},
|
|
876
|
+
},
|
|
877
|
+
product_profile_flow: {
|
|
878
|
+
description: 'Data flows of a product: periodic reads of device resources.',
|
|
879
|
+
version: 'v1',
|
|
880
|
+
parent: 'product',
|
|
881
|
+
path: 'profile/flows',
|
|
882
|
+
id: 'resource',
|
|
883
|
+
upsert: true,
|
|
884
|
+
ops: {
|
|
885
|
+
list: 'Product_ReadProfile',
|
|
886
|
+
create: 'Product_UpdateProductProfileResource',
|
|
887
|
+
get: 'Product_ReadProductProfileResource',
|
|
888
|
+
update: 'Product_UpdateProductProfileResource',
|
|
889
|
+
delete: 'Product_DeleteProductProfileResource',
|
|
890
|
+
},
|
|
891
|
+
notes: PROFILE_NOTES,
|
|
892
|
+
actions: {
|
|
893
|
+
replace_all: {
|
|
894
|
+
op: 'Product_UpdateProfile',
|
|
895
|
+
method: 'put',
|
|
896
|
+
path: '',
|
|
897
|
+
on: 'collection',
|
|
898
|
+
description:
|
|
899
|
+
'Replace every entry of this profile category at once with `body` ({id: entry, …}). The only way to use ids with placeholders such as {{device}}_data, which the per-entry path does not accept.',
|
|
900
|
+
},
|
|
901
|
+
},
|
|
902
|
+
},
|
|
903
|
+
product_profile_code: {
|
|
904
|
+
description: 'Script code of a product (JavaScript run by the server for its devices).',
|
|
905
|
+
version: 'v1',
|
|
906
|
+
parent: 'product',
|
|
907
|
+
path: 'profile/code',
|
|
908
|
+
ops: { get: 'Product_ReadProfileCode', update: 'Product_UpdateProfileCode' },
|
|
909
|
+
notes: 'Update with {code: "<JavaScript>", storage: ""}, or {storage: "<id>"} to load <storage>/index.js, or {storage, path: "<folder>"} to load <storage>/<folder>/index.js, with that folder\'s node_modules (one storage can hold the dashboard in app/ and each product\'s code in code/<product>/). Update again after editing the file. Functions are synchronous globals (`function name(arg) {…}`) that the profile calls by name: API targets, payload_function, device_id_resolver. `Date` runs in UTC: for local hours use Intl.DateTimeFormat with a timeZone (it follows daylight saving). Read {tool:doc_read} id "product-code" before writing it.',
|
|
910
|
+
},
|
|
911
|
+
product_service: {
|
|
912
|
+
description: 'Services a product exposes on its devices.',
|
|
913
|
+
version: 'v1',
|
|
914
|
+
parent: 'product',
|
|
915
|
+
path: 'services',
|
|
916
|
+
id: 'service',
|
|
917
|
+
upsert: true,
|
|
918
|
+
ops: {
|
|
919
|
+
list: 'Product_ReadServices',
|
|
920
|
+
create: 'Product_UpdateServices',
|
|
921
|
+
get: 'Product_ReadServices',
|
|
922
|
+
update: 'Product_UpdateServices',
|
|
923
|
+
delete: 'Product_DeleteServices',
|
|
924
|
+
},
|
|
925
|
+
},
|
|
926
|
+
|
|
927
|
+
// ─── Bucket children ────────────────────────────────────────────
|
|
928
|
+
bucket_tag: {
|
|
929
|
+
description: 'Tags of a bucket and their values.',
|
|
930
|
+
version: 'v1',
|
|
931
|
+
parent: 'bucket',
|
|
932
|
+
path: 'tags',
|
|
933
|
+
id: 'tag',
|
|
934
|
+
ops: { list: 'Bucket_ListTags', get: 'Bucket_ReadTag' },
|
|
935
|
+
},
|
|
936
|
+
bucket_export: {
|
|
937
|
+
description: 'Exports of a bucket (files generated from its data).',
|
|
938
|
+
version: 'v1',
|
|
939
|
+
parent: 'bucket',
|
|
940
|
+
path: 'exports',
|
|
941
|
+
id: 'file',
|
|
942
|
+
bodyId: [],
|
|
943
|
+
ops: {
|
|
944
|
+
list: 'Bucket_ListExports',
|
|
945
|
+
create: 'Bucket_CreateExport',
|
|
946
|
+
delete: 'Bucket_DeleteExport',
|
|
947
|
+
},
|
|
948
|
+
},
|
|
949
|
+
|
|
950
|
+
plugin_property: {
|
|
951
|
+
description: 'Properties of a plugin, usually its configuration.',
|
|
952
|
+
version: 'v1',
|
|
953
|
+
parent: 'plugin',
|
|
954
|
+
path: 'properties',
|
|
955
|
+
id: 'property',
|
|
956
|
+
ops: {
|
|
957
|
+
list: 'Plugin_ListProperties',
|
|
958
|
+
create: 'Plugin_CreateProperty',
|
|
959
|
+
get: 'Plugin_ReadProperty',
|
|
960
|
+
update: 'Plugin_UpdateProperty',
|
|
961
|
+
patch: 'Plugin_PatchProperty',
|
|
962
|
+
delete: 'Plugin_DeleteProperty',
|
|
963
|
+
clone: 'Plugin_CloneProperty',
|
|
964
|
+
},
|
|
965
|
+
notes: PROPERTY_NOTES,
|
|
966
|
+
},
|
|
967
|
+
// ─── Asset and project children ─────────────────────────────────
|
|
968
|
+
asset_type_property: {
|
|
969
|
+
description: 'Properties of an asset type.',
|
|
970
|
+
version: 'v1',
|
|
971
|
+
parent: 'asset_type',
|
|
972
|
+
path: 'properties',
|
|
973
|
+
id: 'property',
|
|
974
|
+
ops: {
|
|
975
|
+
list: 'Type_ListProperties',
|
|
976
|
+
create: 'Type_CreateProperty',
|
|
977
|
+
get: 'Type_ReadProperty',
|
|
978
|
+
update: 'Type_UpdateProperty',
|
|
979
|
+
patch: 'Type_PatchProperty',
|
|
980
|
+
delete: 'Type_DeleteProperty',
|
|
981
|
+
clone: 'Type_CloneProperty',
|
|
982
|
+
},
|
|
983
|
+
notes: PROPERTY_NOTES,
|
|
984
|
+
},
|
|
985
|
+
asset_group_property: {
|
|
986
|
+
description: 'Properties of an asset group.',
|
|
987
|
+
version: 'v1',
|
|
988
|
+
parent: 'asset_group',
|
|
989
|
+
path: 'properties',
|
|
990
|
+
id: 'property',
|
|
991
|
+
ops: {
|
|
992
|
+
list: 'Group_ListProperties',
|
|
993
|
+
create: 'Group_CreateProperty',
|
|
994
|
+
get: 'Group_ReadProperty',
|
|
995
|
+
update: 'Group_UpdateProperty',
|
|
996
|
+
patch: 'Group_PatchProperty',
|
|
997
|
+
delete: 'Group_DeleteProperty',
|
|
998
|
+
clone: 'Group_CloneProperty',
|
|
999
|
+
},
|
|
1000
|
+
notes: PROPERTY_NOTES,
|
|
1001
|
+
},
|
|
1002
|
+
project_property: {
|
|
1003
|
+
description: 'Properties of a project.',
|
|
1004
|
+
version: 'v1',
|
|
1005
|
+
parent: 'project',
|
|
1006
|
+
path: 'properties',
|
|
1007
|
+
id: 'property',
|
|
1008
|
+
ops: {
|
|
1009
|
+
list: 'Project_ListProperties',
|
|
1010
|
+
create: 'Project_CreateProperty',
|
|
1011
|
+
get: 'Project_ReadProperty',
|
|
1012
|
+
update: 'Project_UpdateProperty',
|
|
1013
|
+
patch: 'Project_PatchProperty',
|
|
1014
|
+
delete: 'Project_DeleteProperty',
|
|
1015
|
+
clone: 'Project_CloneProperty',
|
|
1016
|
+
},
|
|
1017
|
+
notes: PROPERTY_NOTES,
|
|
1018
|
+
},
|
|
1019
|
+
project_member: {
|
|
1020
|
+
description: 'Members a project is shared with, and their roles.',
|
|
1021
|
+
version: 'v1',
|
|
1022
|
+
parent: 'project',
|
|
1023
|
+
path: 'members',
|
|
1024
|
+
id: 'member',
|
|
1025
|
+
notes: 'A member is an existing account, by username (`member`), given access to the project: `roles` (project roles), `global_roles` (account roles) and/or its own `allow` / `deny`; `groups` restricts it to asset groups. With `account: {email, password}` the server creates that account first (verified, role member, this project as its default). Removing a member also deletes what it created inside the project. Resources join the project through their own update ({projects: ["<owner>@<project>"]}).',
|
|
1026
|
+
ops: {
|
|
1027
|
+
list: 'Project_ListMembers',
|
|
1028
|
+
create: 'Project_CreateMember',
|
|
1029
|
+
get: 'Project_ReadMemberConfig',
|
|
1030
|
+
update: 'Project_UpdateMember',
|
|
1031
|
+
delete: 'Project_DeleteMember',
|
|
1032
|
+
},
|
|
1033
|
+
},
|
|
1034
|
+
project_role: {
|
|
1035
|
+
description: 'Roles defined inside a project.',
|
|
1036
|
+
version: 'v1',
|
|
1037
|
+
parent: 'project',
|
|
1038
|
+
path: 'roles',
|
|
1039
|
+
id: 'role',
|
|
1040
|
+
notes: 'Roles inside a project ({role, enabled, allow, deny}) to give to its members through `roles`. resource_describe of any type lists the actions its permissions accept.',
|
|
1041
|
+
ops: {
|
|
1042
|
+
list: 'Project_ListRoles',
|
|
1043
|
+
create: 'Project_CreateRole',
|
|
1044
|
+
get: 'Project_ReadRoleConfig',
|
|
1045
|
+
update: 'Project_UpdateRole',
|
|
1046
|
+
delete: 'Project_DeleteRole',
|
|
1047
|
+
clone: 'Project_CloneRole',
|
|
1048
|
+
},
|
|
1049
|
+
},
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
/** HTTP method of each verb, and what it appends to the item path. */
|
|
1053
|
+
export const VERBS = /** @type {const} */ ({
|
|
1054
|
+
list: { method: 'get', on: 'collection' },
|
|
1055
|
+
create: { method: 'post', on: 'collection' },
|
|
1056
|
+
get: { method: 'get', on: 'item' },
|
|
1057
|
+
update: { method: 'put', on: 'item' },
|
|
1058
|
+
patch: { method: 'patch', on: 'item' },
|
|
1059
|
+
delete: { method: 'delete', on: 'item' },
|
|
1060
|
+
clone: { method: 'post', on: 'item', suffix: 'clone' },
|
|
1061
|
+
projects: { method: 'put', on: 'item', suffix: 'projects' },
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Names of the types served, optionally only those supporting `verb`.
|
|
1066
|
+
*
|
|
1067
|
+
* @param {{ verb?: Verb }} [opts]
|
|
1068
|
+
*/
|
|
1069
|
+
export function typeNames({ verb } = {}) {
|
|
1070
|
+
return Object.entries(CATALOG)
|
|
1071
|
+
.filter(([, t]) => !verb || verb in t.ops)
|
|
1072
|
+
.map(([name]) => name);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** Parents of a type, outermost first: `device_bucket_tag` → [device, device_bucket]. */
|
|
1076
|
+
export function ancestry(name) {
|
|
1077
|
+
const chain = [];
|
|
1078
|
+
for (let t = CATALOG[name]?.parent; t; t = CATALOG[t].parent) chain.unshift(t);
|
|
1079
|
+
return chain;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/** Id names of a type, as an array (empty for single resources). */
|
|
1083
|
+
export function idNames(type) {
|
|
1084
|
+
return type.id === undefined ? [] : [type.id].flat();
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** How a type's ids are written: `<device>`. */
|
|
1088
|
+
export function idHint(name) {
|
|
1089
|
+
return idNames(CATALOG[name])
|
|
1090
|
+
.map((n) => `<${n}>`)
|
|
1091
|
+
.join('/');
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/** How the parent of a type is written: `<device>/<bucket>`. */
|
|
1095
|
+
export function parentHint(name) {
|
|
1096
|
+
return ancestry(name).map(idHint).join('/');
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Split a `a/b` reference into exactly `count` non-empty segments.
|
|
1101
|
+
*
|
|
1102
|
+
* @param {unknown} value
|
|
1103
|
+
* @param {number} count
|
|
1104
|
+
* @param {string} what Argument name and expected shape, for the error.
|
|
1105
|
+
*/
|
|
1106
|
+
function segments(value, count, what) {
|
|
1107
|
+
if (count === 0) return [];
|
|
1108
|
+
const parts = value === undefined || value === null ? [] : String(value).split('/');
|
|
1109
|
+
if (parts.length !== count || parts.some((p) => !p)) {
|
|
1110
|
+
throw inputError(`${what} is required`);
|
|
1111
|
+
}
|
|
1112
|
+
return parts;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* The path of a type's collection or item, from the outermost ancestor
|
|
1117
|
+
* down: the real URL (ids escaped, the account from lib/paths.js), or the
|
|
1118
|
+
* OpenAPI template the tests and `resource_describe` match (ids as `{}`).
|
|
1119
|
+
*
|
|
1120
|
+
* @param {string} name
|
|
1121
|
+
* @param {{ on: 'collection' | 'item', suffix?: string, version?: string }} at
|
|
1122
|
+
* @param {{ parent?: string, id?: string, user?: string | null }} args
|
|
1123
|
+
* @param {boolean} template
|
|
1124
|
+
*/
|
|
1125
|
+
function locate(name, at, { parent, id, user } = {}, template = false) {
|
|
1126
|
+
const type = CATALOG[name];
|
|
1127
|
+
const version = at.version || type.version;
|
|
1128
|
+
const chain = ancestry(name);
|
|
1129
|
+
const parentIds = template
|
|
1130
|
+
? []
|
|
1131
|
+
: segments(
|
|
1132
|
+
parent,
|
|
1133
|
+
chain.reduce((n, t) => n + idNames(CATALOG[t]).length, 0),
|
|
1134
|
+
`parent (${parentHint(name)})`,
|
|
1135
|
+
);
|
|
1136
|
+
const seg = (value) => (template ? '{}' : encodeURIComponent(value));
|
|
1137
|
+
|
|
1138
|
+
let path = '';
|
|
1139
|
+
for (const t of chain) {
|
|
1140
|
+
path += `/${CATALOG[t].path}`;
|
|
1141
|
+
for (const _ of idNames(CATALOG[t])) path += `/${seg(parentIds.shift())}`;
|
|
1142
|
+
}
|
|
1143
|
+
path += `/${type.path}`;
|
|
1144
|
+
if (at.on === 'item' && idNames(type).length) {
|
|
1145
|
+
const ids = template
|
|
1146
|
+
? idNames(type).map(() => '{}')
|
|
1147
|
+
: segments(id, idNames(type).length, `id (${idHint(name)})`);
|
|
1148
|
+
path += ids.map((i) => `/${template ? i : encodeURIComponent(i)}`).join('');
|
|
1149
|
+
}
|
|
1150
|
+
if (at.suffix) path += `/${at.suffix}`;
|
|
1151
|
+
|
|
1152
|
+
// Server-wide types (users, hosts, domains…) hang off /v{N}, not off an account.
|
|
1153
|
+
if (type.scope === 'server') return `/${version}${path}`;
|
|
1154
|
+
if (template) return `/${version}/users/{}${path}`;
|
|
1155
|
+
return `${{ v1, v2, v3 }[version](user)}${path}`;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* Where a verb points: collection or item, the method, and the item suffix.
|
|
1160
|
+
*
|
|
1161
|
+
* @returns {{ method: string, at: { on: 'item' | 'collection', suffix?: string } }}
|
|
1162
|
+
*/
|
|
1163
|
+
function verbTarget(type, verb) {
|
|
1164
|
+
const spec = VERBS[verb];
|
|
1165
|
+
const upsertCreate = verb === 'create' && type.upsert;
|
|
1166
|
+
return {
|
|
1167
|
+
method: upsertCreate ? 'put' : spec.method,
|
|
1168
|
+
at: {
|
|
1169
|
+
on: /** @type {'item' | 'collection'} */ (
|
|
1170
|
+
spec.on === 'item' || upsertCreate ? 'item' : 'collection'
|
|
1171
|
+
),
|
|
1172
|
+
suffix: 'suffix' in spec ? spec.suffix : undefined,
|
|
1173
|
+
},
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Resolve the request a verb maps to.
|
|
1179
|
+
*
|
|
1180
|
+
* @param {string} name Catalog type.
|
|
1181
|
+
* @param {Verb} verb
|
|
1182
|
+
* @param {{ parent?: string, id?: string, user?: string | null }} [args]
|
|
1183
|
+
* @returns {{ method: string, url: string }}
|
|
1184
|
+
*/
|
|
1185
|
+
export function requestFor(name, verb, args = {}) {
|
|
1186
|
+
const type = CATALOG[name];
|
|
1187
|
+
if (!type) throw inputError(`Unknown type "${name}"`);
|
|
1188
|
+
if (!(verb in type.ops)) throw inputError(`Type "${name}" does not support ${verb}`);
|
|
1189
|
+
const { method, at } = verbTarget(type, verb);
|
|
1190
|
+
return { method, url: locate(name, at, args) };
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* The OpenAPI path template and method a verb maps to, account part
|
|
1195
|
+
* included (`/v1/users/{}/devices/{}`).
|
|
1196
|
+
*
|
|
1197
|
+
* @param {string} name
|
|
1198
|
+
* @param {Verb} verb
|
|
1199
|
+
*/
|
|
1200
|
+
export function templateFor(name, verb) {
|
|
1201
|
+
const { method, at } = verbTarget(CATALOG[name], verb);
|
|
1202
|
+
return { method, path: locate(name, at, {}, true) };
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* Resolve the request of a type's action (verify a domain, restart a host…).
|
|
1207
|
+
*
|
|
1208
|
+
* @param {string} name
|
|
1209
|
+
* @param {string} action
|
|
1210
|
+
* @param {{ parent?: string, id?: string, user?: string | null }} [args]
|
|
1211
|
+
*/
|
|
1212
|
+
export function actionRequest(name, action, args = {}) {
|
|
1213
|
+
const type = CATALOG[name];
|
|
1214
|
+
if (!type) throw inputError(`Unknown type "${name}"`);
|
|
1215
|
+
const spec = type.actions?.[action];
|
|
1216
|
+
if (!spec) {
|
|
1217
|
+
const known = Object.keys(type.actions || {});
|
|
1218
|
+
throw inputError(
|
|
1219
|
+
`Type "${name}" has no action "${action}"${known.length ? ` (it has: ${known.join(', ')})` : ''}`,
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
return { method: spec.method, url: locate(name, actionAt(spec), args), query: spec.query };
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/** OpenAPI template of an action, like templateFor. */
|
|
1226
|
+
export function actionTemplate(name, action) {
|
|
1227
|
+
const spec = CATALOG[name].actions[action];
|
|
1228
|
+
return { method: spec.method, path: locate(name, actionAt(spec), {}, true) };
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const actionAt = (spec) => ({
|
|
1232
|
+
on: spec.on || 'item',
|
|
1233
|
+
suffix: spec.path || undefined,
|
|
1234
|
+
version: spec.version,
|
|
1235
|
+
});
|