@genrojs/bag 0.10.1
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/LICENSE +176 -0
- package/NOTICE +7 -0
- package/README.md +191 -0
- package/docs/python-alignment.md +156 -0
- package/package.json +51 -0
- package/src/bag-node-container.js +459 -0
- package/src/bag-node.js +665 -0
- package/src/bag.js +2497 -0
- package/src/browser.js +7 -0
- package/src/devtools/html-repr.css +69 -0
- package/src/devtools/html-repr.js +78 -0
- package/src/devtools/index.js +1 -0
- package/src/index.js +9 -0
- package/src/resolver-wire.js +128 -0
- package/src/resolver.js +538 -0
- package/src/resolvers/StorageResolver.js +102 -0
- package/src/resolvers/UrlResolver.js +209 -0
- package/src/resolvers/UuidResolver.js +37 -0
- package/src/resolvers/index.js +9 -0
- package/src/subtype-wire.js +61 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Copyright 2025 Softwell S.r.l. - SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* UrlResolver - resolver that loads content from an HTTP URL.
|
|
5
|
+
*
|
|
6
|
+
* Uses fetchTytx from @genrojs/tytx for typed serialization/deserialization.
|
|
7
|
+
* Faithful port of Python genro_bag.resolvers.url_resolver.UrlResolver.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { fetchTytx } from '@genrojs/tytx';
|
|
11
|
+
import { BagResolver } from '../resolver.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolver that fetches content from an HTTP URL.
|
|
15
|
+
*
|
|
16
|
+
* Supports all HTTP methods and can convert responses to Bag automatically.
|
|
17
|
+
* Uses fetchTytx for TYTX-aware HTTP operations.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* // Simple GET
|
|
21
|
+
* const resolver = new UrlResolver('https://api.example.com/data');
|
|
22
|
+
* const data = await resolver.resolve();
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* // GET with query params, parse as Bag
|
|
26
|
+
* const resolver = new UrlResolver('https://api.example.com/users', {
|
|
27
|
+
* qs: { page: 1, limit: 10 },
|
|
28
|
+
* asBag: true
|
|
29
|
+
* });
|
|
30
|
+
* const users = await resolver.resolve();
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* // POST with body
|
|
34
|
+
* const resolver = new UrlResolver('https://api.example.com/users', {
|
|
35
|
+
* method: 'post',
|
|
36
|
+
* body: { name: 'John', email: 'john@example.com' },
|
|
37
|
+
* asBag: true
|
|
38
|
+
* });
|
|
39
|
+
*/
|
|
40
|
+
export class UrlResolver extends BagResolver {
|
|
41
|
+
static classKwargs = {
|
|
42
|
+
cacheTime: 300,
|
|
43
|
+
readOnly: true,
|
|
44
|
+
retryPolicy: {
|
|
45
|
+
maxAttempts: 3,
|
|
46
|
+
delay: 1.0,
|
|
47
|
+
backoff: 2.0,
|
|
48
|
+
jitter: true,
|
|
49
|
+
on: ['TypeError', 'NetworkError', 'AbortError']
|
|
50
|
+
},
|
|
51
|
+
asBag: false,
|
|
52
|
+
url: null,
|
|
53
|
+
method: 'get',
|
|
54
|
+
qs: null,
|
|
55
|
+
body: null,
|
|
56
|
+
timeout: 5, // seconds (same unit as Python)
|
|
57
|
+
transport: 'json'
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
static classArgs = ['url'];
|
|
61
|
+
|
|
62
|
+
static internalParams = new Set([
|
|
63
|
+
'cacheTime', 'readOnly', 'retryPolicy', 'asBag',
|
|
64
|
+
'url', 'method', 'qs', 'body', 'timeout', 'transport'
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Create a URL resolver.
|
|
69
|
+
*
|
|
70
|
+
* @param {string|Object} urlOrKwargs - URL string or options object.
|
|
71
|
+
* @param {Object} [kwargs] - Additional options if first arg is URL.
|
|
72
|
+
*/
|
|
73
|
+
constructor(urlOrKwargs, kwargs = {}) {
|
|
74
|
+
// Allow positional URL: new UrlResolver('http://...')
|
|
75
|
+
if (typeof urlOrKwargs === 'string') {
|
|
76
|
+
kwargs = { ...kwargs, url: urlOrKwargs };
|
|
77
|
+
} else {
|
|
78
|
+
kwargs = urlOrKwargs || {};
|
|
79
|
+
}
|
|
80
|
+
super(kwargs);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Load data from URL.
|
|
85
|
+
*
|
|
86
|
+
* @param {Object} kwargs - Parameters for this load call.
|
|
87
|
+
* @returns {Promise<*>} Response data.
|
|
88
|
+
*/
|
|
89
|
+
async load(kwargs) {
|
|
90
|
+
let url = this._kw.url;
|
|
91
|
+
const method = this._kw.method;
|
|
92
|
+
const qs = this._kw.qs;
|
|
93
|
+
const timeout = this._kw.timeout;
|
|
94
|
+
const transport = this._kw.transport;
|
|
95
|
+
let body = this._kw.body;
|
|
96
|
+
|
|
97
|
+
// _body overrides constructor body
|
|
98
|
+
if (kwargs._body !== undefined) {
|
|
99
|
+
body = kwargs._body;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Collect path args (arg_0, arg_1, ...) and extra qs params
|
|
103
|
+
const pathArgs = [];
|
|
104
|
+
const extraQs = {};
|
|
105
|
+
|
|
106
|
+
for (const [key, value] of Object.entries(kwargs)) {
|
|
107
|
+
if (key.startsWith('arg_') && value !== null && value !== undefined) {
|
|
108
|
+
const idx = parseInt(key.slice(4), 10);
|
|
109
|
+
if (!isNaN(idx)) {
|
|
110
|
+
while (pathArgs.length <= idx) {
|
|
111
|
+
pathArgs.push(null);
|
|
112
|
+
}
|
|
113
|
+
pathArgs[idx] = value;
|
|
114
|
+
}
|
|
115
|
+
} else if (
|
|
116
|
+
!this.constructor.classKwargs.hasOwnProperty(key) &&
|
|
117
|
+
!this.constructor.internalParams.has(key) &&
|
|
118
|
+
!key.startsWith('_')
|
|
119
|
+
) {
|
|
120
|
+
// Extra kwarg -> query string parameter
|
|
121
|
+
if (value !== null && value !== undefined) {
|
|
122
|
+
extraQs[key] = value;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Substitute path parameters {placeholder} with arg_0, arg_1, ...
|
|
128
|
+
if (pathArgs.length > 0 && url.includes('{')) {
|
|
129
|
+
const placeholders = url.match(/\{([^}]+)\}/g) || [];
|
|
130
|
+
placeholders.forEach((placeholder, i) => {
|
|
131
|
+
if (i < pathArgs.length && pathArgs[i] !== null) {
|
|
132
|
+
url = url.replace(placeholder, String(pathArgs[i]));
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Merge query string: constructor qs + extra kwargs
|
|
138
|
+
const mergedQs = {};
|
|
139
|
+
if (qs) {
|
|
140
|
+
Object.assign(mergedQs, this._qsToDict(qs));
|
|
141
|
+
}
|
|
142
|
+
Object.assign(mergedQs, extraQs);
|
|
143
|
+
|
|
144
|
+
// Build URL with query string (filter null/undefined values)
|
|
145
|
+
if (Object.keys(mergedQs).length > 0) {
|
|
146
|
+
const params = new URLSearchParams();
|
|
147
|
+
for (const [key, value] of Object.entries(mergedQs)) {
|
|
148
|
+
if (value !== null && value !== undefined) {
|
|
149
|
+
params.append(key, String(value));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
153
|
+
url = `${url}${separator}${params.toString()}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Build fetch options
|
|
157
|
+
const fetchOptions = {
|
|
158
|
+
method: method.toUpperCase(),
|
|
159
|
+
transport,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// Add timeout via AbortController
|
|
163
|
+
if (timeout) {
|
|
164
|
+
fetchOptions.signal = AbortSignal.timeout(timeout * 1000);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Add body for POST/PUT/PATCH
|
|
168
|
+
if (body !== null && body !== undefined) {
|
|
169
|
+
fetchOptions.body = body;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Make request using fetchTytx
|
|
173
|
+
const response = await fetchTytx(url, fetchOptions);
|
|
174
|
+
|
|
175
|
+
return response;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Convert query string source to dict, filtering null/undefined values.
|
|
180
|
+
*
|
|
181
|
+
* @param {Object|Map} qs - Query string parameters.
|
|
182
|
+
* @returns {Object} Parameters with null/undefined values removed.
|
|
183
|
+
* @private
|
|
184
|
+
*/
|
|
185
|
+
_qsToDict(qs) {
|
|
186
|
+
if (!qs) return {};
|
|
187
|
+
|
|
188
|
+
// Handle Bag-like objects with keys() method
|
|
189
|
+
if (typeof qs.keys === 'function' && typeof qs.getItem === 'function') {
|
|
190
|
+
const result = {};
|
|
191
|
+
for (const k of qs.keys()) {
|
|
192
|
+
const v = qs.getItem(k);
|
|
193
|
+
if (v !== null && v !== undefined) {
|
|
194
|
+
result[k] = v;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Handle plain objects
|
|
201
|
+
const result = {};
|
|
202
|
+
for (const [k, v] of Object.entries(qs)) {
|
|
203
|
+
if (v !== null && v !== undefined) {
|
|
204
|
+
result[k] = v;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Copyright 2025 Softwell S.r.l. - SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
import { BagResolver } from '../resolver.js';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* UuidResolver - resolver that generates UUIDs.
|
|
8
|
+
*
|
|
9
|
+
* Uses crypto.globalThis.crypto.randomUUID() (Node 18+ and modern browsers).
|
|
10
|
+
*
|
|
11
|
+
* // DIFF-PYTHON: Python supports uuid1 and uuid4 via the version parameter.
|
|
12
|
+
* // JS only supports uuid4 via crypto.globalThis.crypto.randomUUID(). The version parameter
|
|
13
|
+
* // is accepted for API compatibility but only 'uuid4' is implemented.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* const resolver = new UuidResolver();
|
|
17
|
+
* const uuid = resolver.resolve(); // e.g. '550e8400-e29b-41d4-a716-446655440000'
|
|
18
|
+
*/
|
|
19
|
+
export class UuidResolver extends BagResolver {
|
|
20
|
+
static classKwargs = {
|
|
21
|
+
cacheTime: -1,
|
|
22
|
+
readOnly: false,
|
|
23
|
+
asBag: false,
|
|
24
|
+
retryPolicy: null,
|
|
25
|
+
version: 'uuid4'
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
static classArgs = ['version'];
|
|
29
|
+
|
|
30
|
+
static internalParams = new Set([
|
|
31
|
+
'cacheTime', 'readOnly', 'asBag', 'retryPolicy', 'version'
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
load() {
|
|
35
|
+
return globalThis.crypto.randomUUID();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Copyright 2025 Softwell S.r.l. - SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Specialized resolvers for genro-bag.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { UrlResolver } from './UrlResolver.js';
|
|
8
|
+
export { UuidResolver } from './UuidResolver.js';
|
|
9
|
+
export { StorageResolver } from './StorageResolver.js';
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Copyright 2026 Softwell S.r.l. - SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
import { getRegisteredType, getSubtypeDict } from '@genrojs/tytx';
|
|
4
|
+
import { BagSerializationError } from './resolver-wire.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Wire encoding of Bag subclasses: the __cls name, shared by toTytx and fromTytx.
|
|
8
|
+
*
|
|
9
|
+
* A Bag branch travels as "::<suffix>" ("::X" for every Bag subclass that does
|
|
10
|
+
* not declare a suffix of its own). Which class of that type it is travels as a
|
|
11
|
+
* symbolic name, looked up in the TYTX subtype dictionary of the suffix
|
|
12
|
+
* (getSubtypeDict): name -> class, the real class name, identical in Python.
|
|
13
|
+
*
|
|
14
|
+
* A branch carries the name in the attributes of its row; a root (the payload
|
|
15
|
+
* of toTytx, including a Bag held in an attribute or a plain container value)
|
|
16
|
+
* carries it at payload level, next to rows. It is written only when the class
|
|
17
|
+
* differs from the inherited one: a branch inherits the class of its parent
|
|
18
|
+
* Bag when both share the suffix, otherwise the class registered for the
|
|
19
|
+
* suffix; a root inherits the class registered for the suffix.
|
|
20
|
+
*
|
|
21
|
+
* Every class that travels this way is in the dictionary, the base class
|
|
22
|
+
* included (genro-bag-js adds "Bag"). __cls is reserved: a user attribute with
|
|
23
|
+
* that name is an error.
|
|
24
|
+
*/
|
|
25
|
+
export const CLS_ATTRIBUTE = '__cls';
|
|
26
|
+
|
|
27
|
+
/** The class a branch (or, with parentClass null, a root) has without __cls. */
|
|
28
|
+
export function getInheritedClass(parentClass, suffix) {
|
|
29
|
+
if (parentClass !== null && parentClass.tytxSuffix === suffix) return parentClass;
|
|
30
|
+
return getRegisteredType(suffix);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The symbolic name of a Bag class in the subtype dictionary of its suffix. */
|
|
34
|
+
export function getSubtypeName(cls) {
|
|
35
|
+
const suffix = cls.tytxSuffix;
|
|
36
|
+
const names = Object.entries(getSubtypeDict(suffix))
|
|
37
|
+
.filter(([, subtype]) => subtype === cls).map(([name]) => name);
|
|
38
|
+
if (names.length === 0) {
|
|
39
|
+
throw new BagSerializationError(
|
|
40
|
+
`${cls.name} is not in the TYTX subtype dictionary of '${suffix}'`);
|
|
41
|
+
}
|
|
42
|
+
if (names.length > 1) {
|
|
43
|
+
throw new BagSerializationError(
|
|
44
|
+
`${cls.name} is in the TYTX subtype dictionary of '${suffix}' under several names: ${names.sort().join(', ')}`);
|
|
45
|
+
}
|
|
46
|
+
return names[0];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The Bag class registered under a symbolic name for a suffix. */
|
|
50
|
+
export function getSubtypeClass(suffix, name) {
|
|
51
|
+
const subtypes = getSubtypeDict(suffix);
|
|
52
|
+
if (!Object.hasOwn(subtypes, name)) {
|
|
53
|
+
throw new BagSerializationError(`Unknown Bag class '${name}' for TYTX type '${suffix}'`);
|
|
54
|
+
}
|
|
55
|
+
return subtypes[name];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The __cls value to write for cls, or null when it is the inherited class. */
|
|
59
|
+
export function getClsMarker(cls, inherited) {
|
|
60
|
+
return cls === inherited ? null : getSubtypeName(cls);
|
|
61
|
+
}
|