@zeyos/client 0.1.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 +31 -0
- package/LICENSE +21 -0
- package/README.md +458 -0
- package/agents/README.md +66 -0
- package/agents/shared/business-app-benchmarks.md +111 -0
- package/agents/shared/zeyos-entity-map.md +142 -0
- package/agents/shared/zeyos-entity-reference.md +570 -0
- package/agents/shared/zeyos-query-patterns.md +89 -0
- package/agents/zeyos-account-intelligence/SKILL.md +34 -0
- package/agents/zeyos-account-intelligence/agents/openai.yaml +4 -0
- package/agents/zeyos-account-intelligence/references/workflows.md +84 -0
- package/agents/zeyos-billing-insights/SKILL.md +41 -0
- package/agents/zeyos-billing-insights/agents/openai.yaml +4 -0
- package/agents/zeyos-billing-insights/references/workflows.md +106 -0
- package/agents/zeyos-campaign-and-outreach/SKILL.md +44 -0
- package/agents/zeyos-campaign-and-outreach/agents/openai.yaml +4 -0
- package/agents/zeyos-campaign-and-outreach/references/workflows.md +100 -0
- package/agents/zeyos-collaboration-and-activity/SKILL.md +37 -0
- package/agents/zeyos-collaboration-and-activity/agents/openai.yaml +4 -0
- package/agents/zeyos-collaboration-and-activity/references/workflows.md +104 -0
- package/agents/zeyos-collections-and-dunning/SKILL.md +46 -0
- package/agents/zeyos-collections-and-dunning/agents/openai.yaml +4 -0
- package/agents/zeyos-collections-and-dunning/references/workflows.md +132 -0
- package/agents/zeyos-commerce-and-inventory/SKILL.md +38 -0
- package/agents/zeyos-commerce-and-inventory/agents/openai.yaml +4 -0
- package/agents/zeyos-commerce-and-inventory/references/workflows.md +101 -0
- package/agents/zeyos-mail-operations/SKILL.md +35 -0
- package/agents/zeyos-mail-operations/agents/openai.yaml +4 -0
- package/agents/zeyos-mail-operations/references/workflows.md +110 -0
- package/agents/zeyos-notes-and-sops/SKILL.md +31 -0
- package/agents/zeyos-notes-and-sops/agents/openai.yaml +4 -0
- package/agents/zeyos-notes-and-sops/references/workflows.md +85 -0
- package/agents/zeyos-platform-and-schema/SKILL.md +37 -0
- package/agents/zeyos-platform-and-schema/agents/openai.yaml +4 -0
- package/agents/zeyos-platform-and-schema/references/workflows.md +97 -0
- package/agents/zeyos-work-management/SKILL.md +45 -0
- package/agents/zeyos-work-management/agents/openai.yaml +4 -0
- package/agents/zeyos-work-management/references/workflows.md +148 -0
- package/docs/01-api-reference/01-data-retrieval.md +601 -0
- package/docs/01-api-reference/02-authentication.md +288 -0
- package/docs/01-api-reference/03-resources.md +270 -0
- package/docs/01-api-reference/04-schema.md +539 -0
- package/docs/01-api-reference/_category_.json +9 -0
- package/docs/02-javascript-client/01-getting-started.md +146 -0
- package/docs/02-javascript-client/02-authentication.md +287 -0
- package/docs/02-javascript-client/03-making-requests.md +572 -0
- package/docs/02-javascript-client/04-practical-guide.md +348 -0
- package/docs/02-javascript-client/_category_.json +9 -0
- package/docs/03-cli/01-getting-started.md +219 -0
- package/docs/03-cli/02-commands.md +407 -0
- package/docs/03-cli/03-configuration.md +220 -0
- package/docs/03-cli/_category_.json +9 -0
- package/docs/04-agent-workflows/00-coding-agents.md +35 -0
- package/docs/04-agent-workflows/01-agent-quickstart.md +147 -0
- package/docs/04-agent-workflows/02-agent-recipes.md +109 -0
- package/docs/04-agent-workflows/03-cli-coverage-and-escalation.md +65 -0
- package/docs/04-agent-workflows/_category_.json +9 -0
- package/docs/04-sample-apps/01-kanban.md +89 -0
- package/docs/04-sample-apps/02-crm.md +81 -0
- package/docs/04-sample-apps/03-dashboard.md +80 -0
- package/docs/04-sample-apps/_category_.json +9 -0
- package/docs/05-tutorials/00-application-developers.md +43 -0
- package/docs/05-tutorials/01-integration-architecture.md +60 -0
- package/docs/05-tutorials/02-build-your-own-zeyos-frontend.md +517 -0
- package/docs/05-tutorials/03-server-side-integrations.md +185 -0
- package/docs/05-tutorials/_category_.json +9 -0
- package/docs/intro.md +197 -0
- package/openapi/api.json +24308 -0
- package/openapi/auth.json +415 -0
- package/openapi/dbref.json +56223 -0
- package/openapi/oauth2.json +781 -0
- package/openapi/sdk.json +949 -0
- package/openapi/views.txt +642 -0
- package/package.json +49 -0
- package/samples/crm/README.md +28 -0
- package/samples/crm/index.html +327 -0
- package/samples/crm/js/api.js +208 -0
- package/samples/crm/js/auth.js +61 -0
- package/samples/crm/js/main.js +545 -0
- package/samples/crm/js/state.js +90 -0
- package/samples/crm/js/ui.js +51 -0
- package/samples/dashboard/README.md +28 -0
- package/samples/dashboard/index.html +280 -0
- package/samples/dashboard/js/api.js +197 -0
- package/samples/dashboard/js/auth.js +59 -0
- package/samples/dashboard/js/main.js +382 -0
- package/samples/dashboard/js/state.js +81 -0
- package/samples/dashboard/js/ui.js +48 -0
- package/samples/kanban/README.md +28 -0
- package/samples/kanban/index.html +263 -0
- package/samples/kanban/js/api.js +152 -0
- package/samples/kanban/js/auth.js +59 -0
- package/samples/kanban/js/constants.js +40 -0
- package/samples/kanban/js/kanban.js +246 -0
- package/samples/kanban/js/main.js +362 -0
- package/samples/kanban/js/modals.js +474 -0
- package/samples/kanban/js/settings.js +82 -0
- package/samples/kanban/js/state.js +118 -0
- package/samples/kanban/js/ui.js +49 -0
- package/scripts/generate-client.mjs +344 -0
- package/src/generated/operations.js +9772 -0
- package/src/generated/schema.js +8982 -0
- package/src/index.js +85 -0
- package/src/runtime/client.js +1208 -0
- package/src/runtime/error.js +29 -0
- package/src/runtime/http.js +174 -0
- package/src/runtime/request-shape.js +35 -0
- package/src/runtime/schema.js +206 -0
- package/src/runtime/suggest.js +74 -0
- package/src/runtime/token-store.js +105 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export class ZeyosApiError extends Error {
|
|
2
|
+
constructor(message, details = {}) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = 'ZeyosApiError';
|
|
5
|
+
|
|
6
|
+
this.status = details.status ?? 0;
|
|
7
|
+
this.statusText = details.statusText ?? '';
|
|
8
|
+
this.headers = details.headers ?? {};
|
|
9
|
+
this.body = details.body ?? null;
|
|
10
|
+
this.method = details.method ?? '';
|
|
11
|
+
this.url = details.url ?? '';
|
|
12
|
+
this.operationId = details.operationId ?? '';
|
|
13
|
+
this.service = details.service ?? '';
|
|
14
|
+
this.cause = details.cause;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Thrown by pre-flight validation (when `validate: true` is enabled) before a
|
|
20
|
+
* request is sent. Carries structured, self-correcting hints for agents.
|
|
21
|
+
*/
|
|
22
|
+
export class ZeyosValidationError extends Error {
|
|
23
|
+
constructor(message, details = {}) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'ZeyosValidationError';
|
|
26
|
+
this.operationId = details.operationId ?? '';
|
|
27
|
+
this.errors = Array.isArray(details.errors) ? details.errors : [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
function isPlainObject(value) {
|
|
2
|
+
return Object.prototype.toString.call(value) === '[object Object]';
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function encodePrimitive(value) {
|
|
6
|
+
if (value instanceof Date) {
|
|
7
|
+
return value.toISOString();
|
|
8
|
+
}
|
|
9
|
+
return String(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function appendQueryValue(search, key, value) {
|
|
13
|
+
if (value == null) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (Array.isArray(value)) {
|
|
18
|
+
for (const item of value) {
|
|
19
|
+
appendQueryValue(search, key, item);
|
|
20
|
+
}
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (isPlainObject(value)) {
|
|
25
|
+
search.append(key, JSON.stringify(value));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
search.append(key, encodePrimitive(value));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildQueryString(query = {}) {
|
|
33
|
+
const search = new URLSearchParams();
|
|
34
|
+
for (const [key, value] of Object.entries(query)) {
|
|
35
|
+
appendQueryValue(search, key, value);
|
|
36
|
+
}
|
|
37
|
+
return search.toString();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function applyPathParams(pathTemplate, pathParams = {}) {
|
|
41
|
+
return pathTemplate.replace(/\{([^}]+)\}/g, (_, token) => {
|
|
42
|
+
if (!Object.prototype.hasOwnProperty.call(pathParams, token)) {
|
|
43
|
+
throw new Error(`Missing path parameter: ${token}`);
|
|
44
|
+
}
|
|
45
|
+
const rawValue = pathParams[token];
|
|
46
|
+
if (rawValue == null) {
|
|
47
|
+
throw new Error(`Path parameter cannot be null: ${token}`);
|
|
48
|
+
}
|
|
49
|
+
return encodeURIComponent(String(rawValue));
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildUrl(baseUrl, pathTemplate, pathParams = {}, query = {}) {
|
|
54
|
+
const normalizedBase = String(baseUrl || '').replace(/\/+$/, '');
|
|
55
|
+
const resolvedPath = applyPathParams(pathTemplate, pathParams);
|
|
56
|
+
const normalizedPath = resolvedPath.startsWith('/') ? resolvedPath : `/${resolvedPath}`;
|
|
57
|
+
|
|
58
|
+
const rawUrl = `${normalizedBase}${normalizedPath}`;
|
|
59
|
+
const queryString = buildQueryString(query);
|
|
60
|
+
|
|
61
|
+
return queryString ? `${rawUrl}?${queryString}` : rawUrl;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function valueToFormValues(value) {
|
|
65
|
+
if (value == null) {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
return value.flatMap((item) => valueToFormValues(item));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (isPlainObject(value)) {
|
|
74
|
+
return [JSON.stringify(value)];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return [encodePrimitive(value)];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function toFormUrlEncoded(value) {
|
|
81
|
+
const search = new URLSearchParams();
|
|
82
|
+
if (!value || typeof value !== 'object') {
|
|
83
|
+
return search.toString();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const [key, rawValue] of Object.entries(value)) {
|
|
87
|
+
for (const part of valueToFormValues(rawValue)) {
|
|
88
|
+
search.append(key, part);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return search.toString();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function headersToObject(headers) {
|
|
96
|
+
const result = {};
|
|
97
|
+
for (const [key, value] of headers.entries()) {
|
|
98
|
+
result[key] = value;
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function parseResponseBody(response, method) {
|
|
104
|
+
if (method === 'HEAD' || response.status === 204 || response.status === 205 || response.status === 304) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const text = await response.text();
|
|
109
|
+
if (!text) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const contentType = response.headers.get('content-type') || '';
|
|
114
|
+
const isJson = /(^|\b|;)application\/([a-z0-9.+-]*\+)?json\b/i.test(contentType);
|
|
115
|
+
|
|
116
|
+
if (!isJson) {
|
|
117
|
+
return text;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(text);
|
|
122
|
+
} catch {
|
|
123
|
+
return text;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function httpRequest({
|
|
128
|
+
fetchImpl,
|
|
129
|
+
url,
|
|
130
|
+
method,
|
|
131
|
+
headers = {},
|
|
132
|
+
body,
|
|
133
|
+
bodyType,
|
|
134
|
+
signal,
|
|
135
|
+
credentials
|
|
136
|
+
}) {
|
|
137
|
+
const requestHeaders = new Headers(headers);
|
|
138
|
+
let payload;
|
|
139
|
+
|
|
140
|
+
if (body != null) {
|
|
141
|
+
if (bodyType === 'form') {
|
|
142
|
+
if (!requestHeaders.has('content-type')) {
|
|
143
|
+
requestHeaders.set('content-type', 'application/x-www-form-urlencoded');
|
|
144
|
+
}
|
|
145
|
+
payload = toFormUrlEncoded(body);
|
|
146
|
+
} else if (bodyType === 'json') {
|
|
147
|
+
if (!requestHeaders.has('content-type')) {
|
|
148
|
+
requestHeaders.set('content-type', 'application/json');
|
|
149
|
+
}
|
|
150
|
+
payload = JSON.stringify(body);
|
|
151
|
+
} else {
|
|
152
|
+
payload = body;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const response = await fetchImpl(url, {
|
|
157
|
+
method,
|
|
158
|
+
headers: requestHeaders,
|
|
159
|
+
body: payload,
|
|
160
|
+
signal,
|
|
161
|
+
credentials
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const data = await parseResponseBody(response, method);
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
ok: response.ok,
|
|
168
|
+
status: response.status,
|
|
169
|
+
statusText: response.statusText,
|
|
170
|
+
headers: headersToObject(response.headers),
|
|
171
|
+
data,
|
|
172
|
+
response
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-level keys accepted by generated methods and `client.request()`.
|
|
3
|
+
* These are control fields consumed by the client, not resource payload fields.
|
|
4
|
+
*/
|
|
5
|
+
export const REQUEST_CONTROL_KEYS = Object.freeze([
|
|
6
|
+
'path',
|
|
7
|
+
'query',
|
|
8
|
+
'headers',
|
|
9
|
+
'body',
|
|
10
|
+
'data',
|
|
11
|
+
'auth',
|
|
12
|
+
'bodyType',
|
|
13
|
+
'signal',
|
|
14
|
+
'raw',
|
|
15
|
+
'baseUrl'
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Validation accepts request options as a second argument, so `validate` is only
|
|
20
|
+
* a field-validation control key, not a request payload control key.
|
|
21
|
+
*/
|
|
22
|
+
export const VALIDATION_CONTROL_KEYS = Object.freeze([
|
|
23
|
+
...REQUEST_CONTROL_KEYS,
|
|
24
|
+
'validate'
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Control keys that act as containers only when object-valued. A scalar `query`
|
|
29
|
+
* can be a legitimate ZeyOS payload field for full-text search.
|
|
30
|
+
*/
|
|
31
|
+
export const OBJECT_CONTROL_KEYS = Object.freeze([
|
|
32
|
+
'path',
|
|
33
|
+
'query',
|
|
34
|
+
'headers'
|
|
35
|
+
]);
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { suggestClosest } from './suggest.js';
|
|
2
|
+
import { VALIDATION_CONTROL_KEYS } from './request-shape.js';
|
|
3
|
+
|
|
4
|
+
// Top-level body keys on list/count queries are query directives, not resource
|
|
5
|
+
// fields. Resource field names appear *inside* `filters`/`filter`/`fields`.
|
|
6
|
+
const QUERY_DIRECTIVES = new Set([
|
|
7
|
+
'fields', 'filter', 'filters', 'sort', 'limit', 'offset', 'count',
|
|
8
|
+
'query', 'distinct', 'expand', 'extdata', 'tags', 'group', 'having', 'visibility'
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
// Request-level control keys consumed by the client, never resource fields.
|
|
12
|
+
const CONTROL_KEYS = new Set(VALIDATION_CONTROL_KEYS);
|
|
13
|
+
|
|
14
|
+
// The ZeyOS OpenAPI spec carries NO required-field metadata — every schema's
|
|
15
|
+
// `required` array is empty — yet some columns are NOT NULL with no DB default, so
|
|
16
|
+
// a create that omits them fails server-side with an opaque HTTP 500 (a raw pg
|
|
17
|
+
// constraint error). This curated map supplements the spec with confirmed-required
|
|
18
|
+
// create fields so validate() can surface a clean, self-correcting hint instead.
|
|
19
|
+
// Keyed by schema resource name; extend as other NOT-NULL-without-default columns
|
|
20
|
+
// are confirmed.
|
|
21
|
+
const REQUIRED_CREATE_FIELDS = {
|
|
22
|
+
accounts: ['currency']
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function resourceFromPath(path) {
|
|
26
|
+
if (typeof path !== 'string') return null;
|
|
27
|
+
for (const segment of path.split('/')) {
|
|
28
|
+
if (segment && !segment.startsWith('{')) return segment;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A field reference may be a dot-notation join (`contact.city`), an extended
|
|
34
|
+
// field (`extdata.region`) or an alias map value. Reduce it to the base column
|
|
35
|
+
// name on the primary resource so it can be checked against the schema.
|
|
36
|
+
function baseFieldName(ref) {
|
|
37
|
+
if (typeof ref !== 'string') return null;
|
|
38
|
+
const head = ref.split('.')[0].trim();
|
|
39
|
+
return head || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build the read-only `client.schema` surface: runtime introspection of
|
|
44
|
+
* resources, fields, enums and operations, plus best-effort input validation
|
|
45
|
+
* that produces agent-friendly, self-correcting hints.
|
|
46
|
+
*/
|
|
47
|
+
export function createSchema({ services, schema }) {
|
|
48
|
+
const schemaMap = schema && typeof schema === 'object' ? schema : {};
|
|
49
|
+
const resourceNames = Object.keys(schemaMap);
|
|
50
|
+
|
|
51
|
+
const opIndex = new Map();
|
|
52
|
+
const allOperationIds = [];
|
|
53
|
+
for (const [serviceKey, service] of Object.entries(services || {})) {
|
|
54
|
+
for (const operation of service.operations || []) {
|
|
55
|
+
allOperationIds.push(operation.operationId);
|
|
56
|
+
opIndex.set(operation.operationId, {
|
|
57
|
+
service: serviceKey,
|
|
58
|
+
operation,
|
|
59
|
+
resource: resourceFromPath(operation.path)
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resources() {
|
|
65
|
+
return resourceNames.slice();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function describe(resource) {
|
|
69
|
+
const entry = schemaMap[resource];
|
|
70
|
+
if (!entry) return null;
|
|
71
|
+
return { name: resource, type: entry.type, fields: entry.fields };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fields(resource) {
|
|
75
|
+
const entry = schemaMap[resource];
|
|
76
|
+
return entry ? Object.keys(entry.fields) : [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function operationIds() {
|
|
80
|
+
return allOperationIds.slice();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function operations(resource) {
|
|
84
|
+
if (resource == null) return allOperationIds.slice();
|
|
85
|
+
return allOperationIds.filter((id) => opIndex.get(id)?.resource === resource);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resourceForOperation(operationId) {
|
|
89
|
+
return opIndex.get(operationId)?.resource ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function suggestOperation(name) {
|
|
93
|
+
return suggestClosest(name, allOperationIds);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function checkField(resourceFields, fieldDefs, ref, value, errors) {
|
|
97
|
+
const base = baseFieldName(ref);
|
|
98
|
+
if (!base) return;
|
|
99
|
+
// Dot-notation joins and extended/custom fields can't be validated against
|
|
100
|
+
// the base table — accept them rather than emit false positives.
|
|
101
|
+
if (base === 'extdata' || base !== ref) return;
|
|
102
|
+
if (!resourceFields.includes(base)) {
|
|
103
|
+
const suggestion = suggestClosest(base, resourceFields);
|
|
104
|
+
errors.push({
|
|
105
|
+
field: base,
|
|
106
|
+
message: `Unknown field "${base}".` + (suggestion ? ` Did you mean "${suggestion}"?` : ''),
|
|
107
|
+
...(suggestion ? { suggestion } : {})
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const def = fieldDefs[base];
|
|
112
|
+
if (def && def.enum && (typeof value === 'string' || typeof value === 'number')) {
|
|
113
|
+
if (!Object.prototype.hasOwnProperty.call(def.enum, String(value))) {
|
|
114
|
+
const valid = Object.entries(def.enum).map(([k, v]) => `${k}=${v}`).join(', ');
|
|
115
|
+
errors.push({
|
|
116
|
+
field: base,
|
|
117
|
+
message: `Invalid value ${JSON.stringify(value)} for "${base}". Valid: ${valid}.`
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Validate an operation call without sending it. Never throws.
|
|
125
|
+
* @returns {{ valid: boolean, errors: { field?: string, message: string, suggestion?: string }[] }}
|
|
126
|
+
*/
|
|
127
|
+
function validate(operationId, input) {
|
|
128
|
+
const errors = [];
|
|
129
|
+
const entry = opIndex.get(operationId);
|
|
130
|
+
if (!entry) {
|
|
131
|
+
const suggestion = suggestOperation(operationId);
|
|
132
|
+
errors.push({
|
|
133
|
+
message: `Unknown operation "${operationId}".` + (suggestion ? ` Did you mean "${suggestion}"?` : ''),
|
|
134
|
+
...(suggestion ? { suggestion } : {})
|
|
135
|
+
});
|
|
136
|
+
return { valid: false, errors };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const data = input && typeof input === 'object' ? input : {};
|
|
140
|
+
const resourceEntry = schemaMap[entry.resource];
|
|
141
|
+
const resourceFields = resourceEntry ? Object.keys(resourceEntry.fields) : null;
|
|
142
|
+
const fieldDefs = resourceEntry ? resourceEntry.fields : {};
|
|
143
|
+
const isListLike = /^(list|count)/.test(operationId);
|
|
144
|
+
|
|
145
|
+
if (isListLike && Object.prototype.hasOwnProperty.call(data, 'filter')) {
|
|
146
|
+
errors.push({
|
|
147
|
+
field: 'filter',
|
|
148
|
+
message: 'Use "filters" (plural) rather than "filter" — it also matches GIN-indexed foreign-key fields (project, account, ticket).',
|
|
149
|
+
suggestion: 'filters'
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!resourceFields) {
|
|
154
|
+
return { valid: errors.length === 0, errors };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (isListLike) {
|
|
158
|
+
for (const key of ['filters', 'filter']) {
|
|
159
|
+
const filterObj = data[key];
|
|
160
|
+
if (filterObj && typeof filterObj === 'object' && !Array.isArray(filterObj)) {
|
|
161
|
+
for (const [field, value] of Object.entries(filterObj)) {
|
|
162
|
+
checkField(resourceFields, fieldDefs, field, value, errors);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const sel = data.fields;
|
|
167
|
+
const selValues = Array.isArray(sel) ? sel : (sel && typeof sel === 'object' ? Object.values(sel) : []);
|
|
168
|
+
for (const ref of selValues) checkField(resourceFields, fieldDefs, ref, undefined, errors);
|
|
169
|
+
} else {
|
|
170
|
+
for (const [key, value] of Object.entries(data)) {
|
|
171
|
+
if (CONTROL_KEYS.has(key) || QUERY_DIRECTIVES.has(key)) continue;
|
|
172
|
+
if (entry.operation.parameterNames?.path?.includes(key)) continue;
|
|
173
|
+
if (entry.operation.parameterNames?.query?.includes(key)) continue;
|
|
174
|
+
if (entry.operation.parameterNames?.header?.includes(key)) continue;
|
|
175
|
+
checkField(resourceFields, fieldDefs, key, value, errors);
|
|
176
|
+
}
|
|
177
|
+
// Create-only required-field check. The spec marks nothing required, so this
|
|
178
|
+
// uses the curated REQUIRED_CREATE_FIELDS supplement. Updates are partial by
|
|
179
|
+
// nature, so the check applies to `create*` operations only.
|
|
180
|
+
if (/^create/i.test(operationId)) {
|
|
181
|
+
for (const field of REQUIRED_CREATE_FIELDS[entry.resource] || []) {
|
|
182
|
+
if (!Object.prototype.hasOwnProperty.call(data, field) || data[field] == null) {
|
|
183
|
+
errors.push({
|
|
184
|
+
field,
|
|
185
|
+
message: `Missing required field "${field}" for ${entry.resource} — it is NOT NULL with no default, so the API rejects a create without it.`,
|
|
186
|
+
suggestion: field
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { valid: errors.length === 0, errors };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return Object.freeze({
|
|
197
|
+
resources,
|
|
198
|
+
describe,
|
|
199
|
+
fields,
|
|
200
|
+
operations,
|
|
201
|
+
operationIds,
|
|
202
|
+
resourceForOperation,
|
|
203
|
+
suggestOperation,
|
|
204
|
+
validate
|
|
205
|
+
});
|
|
206
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Small, dependency-free fuzzy matcher used to turn "not found" failures into
|
|
2
|
+
// self-correcting, agent-friendly errors ("did you mean ...?").
|
|
3
|
+
|
|
4
|
+
function levenshtein(a, b) {
|
|
5
|
+
const m = a.length;
|
|
6
|
+
const n = b.length;
|
|
7
|
+
if (m === 0) return n;
|
|
8
|
+
if (n === 0) return m;
|
|
9
|
+
|
|
10
|
+
let prev = new Array(n + 1);
|
|
11
|
+
let curr = new Array(n + 1);
|
|
12
|
+
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
13
|
+
|
|
14
|
+
for (let i = 1; i <= m; i++) {
|
|
15
|
+
curr[0] = i;
|
|
16
|
+
const ca = a.charCodeAt(i - 1);
|
|
17
|
+
for (let j = 1; j <= n; j++) {
|
|
18
|
+
const cost = ca === b.charCodeAt(j - 1) ? 0 : 1;
|
|
19
|
+
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
20
|
+
}
|
|
21
|
+
const tmp = prev;
|
|
22
|
+
prev = curr;
|
|
23
|
+
curr = tmp;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return prev[n];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Return the closest candidate to `name`, or null when nothing is close enough.
|
|
31
|
+
* Case-insensitive. Prefers an exact case-insensitive match, then a substring
|
|
32
|
+
* match, then the smallest edit distance within a length-scaled threshold.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} name
|
|
35
|
+
* @param {Iterable<string>} candidates
|
|
36
|
+
* @returns {string|null}
|
|
37
|
+
*/
|
|
38
|
+
export function suggestClosest(name, candidates) {
|
|
39
|
+
if (typeof name !== 'string' || !name) return null;
|
|
40
|
+
const list = Array.from(candidates);
|
|
41
|
+
const lowerName = name.toLowerCase();
|
|
42
|
+
|
|
43
|
+
let exact = null;
|
|
44
|
+
let substring = null;
|
|
45
|
+
let best = null;
|
|
46
|
+
let bestDistance = Infinity;
|
|
47
|
+
|
|
48
|
+
for (const candidate of list) {
|
|
49
|
+
if (typeof candidate !== 'string' || !candidate) continue;
|
|
50
|
+
const lower = candidate.toLowerCase();
|
|
51
|
+
|
|
52
|
+
if (lower === lowerName) {
|
|
53
|
+
exact = candidate;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
if (!substring && (lower.includes(lowerName) || lowerName.includes(lower))) {
|
|
57
|
+
substring = candidate;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const distance = levenshtein(lowerName, lower);
|
|
61
|
+
if (distance < bestDistance) {
|
|
62
|
+
bestDistance = distance;
|
|
63
|
+
best = candidate;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (exact) return exact;
|
|
68
|
+
if (substring) return substring;
|
|
69
|
+
|
|
70
|
+
// Accept the nearest match only when the edit distance is small relative to
|
|
71
|
+
// the name length, so unrelated names don't produce misleading suggestions.
|
|
72
|
+
const threshold = Math.max(2, Math.floor(name.length / 3));
|
|
73
|
+
return bestDistance <= threshold ? best : null;
|
|
74
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {{
|
|
3
|
+
* accessToken: string|null,
|
|
4
|
+
* refreshToken: string|null,
|
|
5
|
+
* tokenType: string,
|
|
6
|
+
* expiresIn: number|null,
|
|
7
|
+
* refreshTokenExpiresIn: number|null,
|
|
8
|
+
* obtainedAt: number,
|
|
9
|
+
* expiresAt: number|null,
|
|
10
|
+
* refreshTokenExpiresAt: number|null
|
|
11
|
+
* }} TokenSet
|
|
12
|
+
*
|
|
13
|
+
* @typedef {{
|
|
14
|
+
* accessToken?: string|null,
|
|
15
|
+
* access_token?: string|null,
|
|
16
|
+
* refreshToken?: string|null,
|
|
17
|
+
* refresh_token?: string|null,
|
|
18
|
+
* tokenType?: string|null,
|
|
19
|
+
* token_type?: string|null,
|
|
20
|
+
* expiresIn?: number|string|null,
|
|
21
|
+
* expires_in?: number|string|null,
|
|
22
|
+
* refreshTokenExpiresIn?: number|string|null,
|
|
23
|
+
* refresh_token_expires_in?: number|string|null,
|
|
24
|
+
* obtainedAt?: number|string|null,
|
|
25
|
+
* obtained_at?: number|string|null,
|
|
26
|
+
* expiresAt?: number|string|null,
|
|
27
|
+
* expires_at?: number|string|null,
|
|
28
|
+
* refreshTokenExpiresAt?: number|string|null,
|
|
29
|
+
* refresh_token_expires_at?: number|string|null
|
|
30
|
+
* }} TokenSetInput
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
function toNumber(value) {
|
|
34
|
+
if (value == null || value === '') {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const number = Number(value);
|
|
38
|
+
return Number.isFinite(number) ? number : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {TokenSetInput|null|undefined} input
|
|
43
|
+
* @returns {TokenSet|null}
|
|
44
|
+
*/
|
|
45
|
+
export function normalizeTokenSet(input) {
|
|
46
|
+
if (!input || typeof input !== 'object') {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const accessToken = input.accessToken ?? input.access_token ?? null;
|
|
51
|
+
const refreshToken = input.refreshToken ?? input.refresh_token ?? null;
|
|
52
|
+
|
|
53
|
+
if (!accessToken && !refreshToken) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
58
|
+
const obtainedAt = toNumber(input.obtainedAt ?? input.obtained_at) ?? nowSeconds;
|
|
59
|
+
const expiresIn = toNumber(input.expiresIn ?? input.expires_in);
|
|
60
|
+
const refreshTokenExpiresIn = toNumber(input.refreshTokenExpiresIn ?? input.refresh_token_expires_in);
|
|
61
|
+
|
|
62
|
+
const expiresAt =
|
|
63
|
+
toNumber(input.expiresAt ?? input.expires_at) ??
|
|
64
|
+
(expiresIn != null ? obtainedAt + expiresIn : null);
|
|
65
|
+
|
|
66
|
+
const refreshTokenExpiresAt =
|
|
67
|
+
toNumber(input.refreshTokenExpiresAt ?? input.refresh_token_expires_at) ??
|
|
68
|
+
(refreshTokenExpiresIn != null ? obtainedAt + refreshTokenExpiresIn : null);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
tokenType: input.tokenType ?? input.token_type ?? 'Bearer',
|
|
72
|
+
accessToken,
|
|
73
|
+
refreshToken,
|
|
74
|
+
expiresIn,
|
|
75
|
+
refreshTokenExpiresIn,
|
|
76
|
+
obtainedAt,
|
|
77
|
+
expiresAt,
|
|
78
|
+
refreshTokenExpiresAt
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {TokenSetInput|null|undefined} tokenResponse
|
|
84
|
+
* @returns {TokenSet|null}
|
|
85
|
+
*/
|
|
86
|
+
export function tokenResponseToTokenSet(tokenResponse) {
|
|
87
|
+
return normalizeTokenSet(tokenResponse);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class MemoryTokenStore {
|
|
91
|
+
/** @param {TokenSetInput|null} [initialToken] */
|
|
92
|
+
constructor(initialToken = null) {
|
|
93
|
+
this.token = normalizeTokenSet(initialToken);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @returns {Promise<TokenSet|null>} */
|
|
97
|
+
async get() {
|
|
98
|
+
return this.token;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @param {TokenSetInput|null} token */
|
|
102
|
+
async set(token) {
|
|
103
|
+
this.token = normalizeTokenSet(token);
|
|
104
|
+
}
|
|
105
|
+
}
|