@zenera/gym 0.0.0-stage → 1.1.38
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 +21 -0
- package/README.md +200 -2
- package/dist/box.d.ts +66 -0
- package/dist/box.js +259 -0
- package/dist/cache.d.ts +87 -0
- package/dist/cache.js +275 -0
- package/dist/command.d.ts +3 -0
- package/dist/command.js +414 -0
- package/dist/envelope.d.ts +13 -0
- package/dist/envelope.js +9 -0
- package/dist/generate.d.ts +57 -0
- package/dist/generate.js +236 -0
- package/dist/image.d.ts +25 -0
- package/dist/image.js +81 -0
- package/dist/logger.d.ts +89 -0
- package/dist/logger.js +321 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +363 -0
- package/dist/page.d.ts +3 -0
- package/dist/page.js +499 -0
- package/dist/paging.d.ts +79 -0
- package/dist/paging.js +518 -0
- package/dist/probe.d.ts +12 -0
- package/dist/probe.js +307 -0
- package/dist/prompt.d.ts +25 -0
- package/dist/prompt.js +188 -0
- package/dist/router.d.ts +20 -0
- package/dist/router.js +101 -0
- package/dist/schema.d.ts +21 -0
- package/dist/schema.js +296 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +626 -0
- package/dist/setup.d.ts +53 -0
- package/dist/setup.js +123 -0
- package/dist/spec.d.ts +53 -0
- package/dist/spec.js +303 -0
- package/dist/validate.d.ts +21 -0
- package/dist/validate.js +83 -0
- package/package.json +53 -5
package/dist/setup.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { credentialError, ensureHome, ensurePodmanReady, envNames, home, invalidError, KeyStore, paths, PROVIDERS, readJson, writeJson, } from '@zenera/cli/lib';
|
|
2
|
+
import { createModel } from '@zenera/neo';
|
|
3
|
+
import { mkdirSync } from 'node:fs';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { Box } from "./box.js";
|
|
6
|
+
import { Cache } from "./cache.js";
|
|
7
|
+
import { ensureImage } from "./image.js";
|
|
8
|
+
import { Router } from "./router.js";
|
|
9
|
+
import { loadSpecs, SpecError } from "./spec.js";
|
|
10
|
+
import { Checks } from "./validate.js";
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Assembly
|
|
13
|
+
//
|
|
14
|
+
// The order here is the whole point and it is the same order `zen run` uses:
|
|
15
|
+
// credentials before anything that needs one, the container engine before the
|
|
16
|
+
// image, the image before the box, and the documents last — so the failure a
|
|
17
|
+
// user sees is the first thing that was actually wrong rather than whatever
|
|
18
|
+
// happened to be checked first.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/**
|
|
21
|
+
* The model each provider gets when none is named — the same picks `zen meta`
|
|
22
|
+
* recommends (`RECOMMENDED` in `packages/cli/src/meta/model.ts`). Every ref names its
|
|
23
|
+
* provider: the shorthand reads the first segment as a *provider name*, so a
|
|
24
|
+
* bare `gemini-3.8-flash` would be asked of OpenAI.
|
|
25
|
+
*/
|
|
26
|
+
const DEFAULT_MODEL = {
|
|
27
|
+
openai: 'openai:gpt-5.6-sol',
|
|
28
|
+
anthropic: 'anthropic:claude-opus-5',
|
|
29
|
+
google: 'google:gemini-3.8-flash',
|
|
30
|
+
vertex: 'vertex:gemini-3.8-flash',
|
|
31
|
+
openrouter: 'openrouter:anthropic/claude-opus-5',
|
|
32
|
+
};
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Which model, and why that one
|
|
35
|
+
//
|
|
36
|
+
// The same chain `zen meta` uses: a flag beats the environment, the environment
|
|
37
|
+
// beats what was stored, and with none of them set it is whatever the keys on
|
|
38
|
+
// hand can buy. Which model writes generators is a choice about a tool on this
|
|
39
|
+
// machine and not about a document, so it is stored beside the keyring rather
|
|
40
|
+
// than anywhere near a specification.
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
/** The variable a shell or a CI job names it with — a zen ref. */
|
|
43
|
+
export const MODEL_ENV = 'ZENERA_GYM_MODEL';
|
|
44
|
+
export const SOURCE_LABELS = {
|
|
45
|
+
flag: '--model',
|
|
46
|
+
env: MODEL_ENV,
|
|
47
|
+
store: 'zen gym model',
|
|
48
|
+
default: 'recommended',
|
|
49
|
+
};
|
|
50
|
+
const EMPTY = { version: 1 };
|
|
51
|
+
const settingsPath = () => join(home(), 'gym.json');
|
|
52
|
+
export const readSettings = () => readJson(settingsPath(), EMPTY);
|
|
53
|
+
export function writeSettings(file) {
|
|
54
|
+
ensureHome();
|
|
55
|
+
writeJson(settingsPath(), file);
|
|
56
|
+
}
|
|
57
|
+
/** What `open` would run, and where it came from, so a command can say so too. */
|
|
58
|
+
export async function chooseModel(flag, keys) {
|
|
59
|
+
const chain = [
|
|
60
|
+
['flag', flag],
|
|
61
|
+
['env', process.env[MODEL_ENV]],
|
|
62
|
+
['store', (await readSettings()).model],
|
|
63
|
+
['default', defaultRef(keys)],
|
|
64
|
+
];
|
|
65
|
+
for (const [from, ref] of chain) {
|
|
66
|
+
const named = ref?.trim();
|
|
67
|
+
if (named) {
|
|
68
|
+
return { ref: named, from };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
export async function open(opts) {
|
|
74
|
+
if (opts.specs.length === 0) {
|
|
75
|
+
throw invalidError('no specification given', 'name one or more openapi/swagger files');
|
|
76
|
+
}
|
|
77
|
+
// Real environment variables win, exactly as they do for `zen`.
|
|
78
|
+
ensureHome();
|
|
79
|
+
const keys = await KeyStore.open();
|
|
80
|
+
keys.materialize();
|
|
81
|
+
const choice = await chooseModel(opts.model, keys);
|
|
82
|
+
if (!choice) {
|
|
83
|
+
throw credentialError('no credentials for any provider', 'add one with: zen key add openai');
|
|
84
|
+
}
|
|
85
|
+
const model = createModel(choice.ref);
|
|
86
|
+
const root = resolve(opts.cwd, opts.cache ?? paths.gym());
|
|
87
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
88
|
+
// Podman is asked about before the image is built, so a machine without a
|
|
89
|
+
// container engine says so instead of failing halfway through a build.
|
|
90
|
+
await ensurePodmanReady({ image: opts.image, yes: true });
|
|
91
|
+
const image = opts.image ?? (await ensureImage({ root, onBuild: opts.onImageBuild }));
|
|
92
|
+
const operations = await loadSpecs(opts.specs.map((s) => resolve(opts.cwd, s)));
|
|
93
|
+
if (operations.length === 0) {
|
|
94
|
+
throw invalidError('the specification declares no operations');
|
|
95
|
+
}
|
|
96
|
+
const router = new Router(operations);
|
|
97
|
+
const checks = new Checks();
|
|
98
|
+
const box = new Box({ root, image, timeout: opts.timeout });
|
|
99
|
+
await box.fresh();
|
|
100
|
+
const cache = new Cache({
|
|
101
|
+
box,
|
|
102
|
+
checks,
|
|
103
|
+
model,
|
|
104
|
+
attempts: opts.attempts,
|
|
105
|
+
concurrency: opts.concurrency,
|
|
106
|
+
rebuild: opts.rebuild,
|
|
107
|
+
ephemeral: opts.ephemeral,
|
|
108
|
+
...opts.events,
|
|
109
|
+
});
|
|
110
|
+
return { router, checks, cache, box, model, choice, image, root, close: () => box.dispose() };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Presence, not liveness. `zen init` probes because it is writing a project
|
|
114
|
+
* that has to work later; this is about to make a call anyway, and the call
|
|
115
|
+
* itself is a better test than a round trip that costs the same.
|
|
116
|
+
*/
|
|
117
|
+
function defaultRef(keys) {
|
|
118
|
+
const provider = PROVIDERS.find((p) => envNames(p).some((name) => process.env[name])) ??
|
|
119
|
+
PROVIDERS.find((p) => keys.active(p) !== undefined);
|
|
120
|
+
return provider ? DEFAULT_MODEL[provider] : undefined;
|
|
121
|
+
}
|
|
122
|
+
export { SpecError };
|
|
123
|
+
//# sourceMappingURL=setup.js.map
|
package/dist/spec.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { CliError } from '@zenera/cli/lib';
|
|
2
|
+
import { type Paging } from './paging.ts';
|
|
3
|
+
import { type Schema } from './schema.ts';
|
|
4
|
+
/**
|
|
5
|
+
* Version of generator prompt rules and environment contracts.
|
|
6
|
+
* Bumping this invalidates cached Python generators and triggers regeneration.
|
|
7
|
+
*/
|
|
8
|
+
export declare const GENERATOR_RULES_VERSION = 2;
|
|
9
|
+
export declare const METHODS: readonly ["get", "put", "post", "delete", "patch", "head", "options"];
|
|
10
|
+
export type Method = (typeof METHODS)[number];
|
|
11
|
+
export type ParamIn = 'path' | 'query' | 'header' | 'cookie';
|
|
12
|
+
export interface ParamSpec {
|
|
13
|
+
name: string;
|
|
14
|
+
in: ParamIn;
|
|
15
|
+
required: boolean;
|
|
16
|
+
schema: Schema;
|
|
17
|
+
description?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface Operation {
|
|
20
|
+
/** cache identity — a pure function of the operation's shape, see `keyOf` */
|
|
21
|
+
key: string;
|
|
22
|
+
/** the document it came from, for error messages */
|
|
23
|
+
source: string;
|
|
24
|
+
method: Method;
|
|
25
|
+
/** the template, `/users/{user_id}` */
|
|
26
|
+
path: string;
|
|
27
|
+
operationId: string;
|
|
28
|
+
summary?: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
params: ParamSpec[];
|
|
31
|
+
/** query values the path key pinned down, see `splitQuery` */
|
|
32
|
+
fixed?: Record<string, string>;
|
|
33
|
+
requestBody?: {
|
|
34
|
+
required: boolean;
|
|
35
|
+
schema: Schema;
|
|
36
|
+
};
|
|
37
|
+
/** the response a call is answered with; `schema` absent means no body */
|
|
38
|
+
success: {
|
|
39
|
+
status: number;
|
|
40
|
+
schema?: Schema;
|
|
41
|
+
};
|
|
42
|
+
/** how the operation turns pages, when it turns pages at all */
|
|
43
|
+
paging?: Paging;
|
|
44
|
+
}
|
|
45
|
+
/** A `CliError` so an unreadable document exits 3 wherever it is raised. */
|
|
46
|
+
export declare class SpecError extends CliError {
|
|
47
|
+
constructor(message: string, hint?: string);
|
|
48
|
+
}
|
|
49
|
+
export declare function loadSpecs(files: readonly string[]): Promise<Operation[]>;
|
|
50
|
+
export declare function loadSpec(file: string): Promise<Operation[]>;
|
|
51
|
+
/** The path as it has to be called — what a person needs to see. */
|
|
52
|
+
export declare const called: (op: Operation) => string;
|
|
53
|
+
//# sourceMappingURL=spec.d.ts.map
|
package/dist/spec.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import SwaggerParser from '@apidevtools/swagger-parser';
|
|
2
|
+
import { CliError, EXIT } from '@zenera/cli/lib';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { pagingOf } from "./paging.js";
|
|
5
|
+
import { normalize } from "./schema.js";
|
|
6
|
+
/**
|
|
7
|
+
* Version of generator prompt rules and environment contracts.
|
|
8
|
+
* Bumping this invalidates cached Python generators and triggers regeneration.
|
|
9
|
+
*/
|
|
10
|
+
export const GENERATOR_RULES_VERSION = 2;
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Documents, flattened
|
|
13
|
+
//
|
|
14
|
+
// Everything below this file works on `Operation`, which is one method on one
|
|
15
|
+
// path with its schemas already dereferenced and already 2020-12. Which
|
|
16
|
+
// document it came from, and which of the three dialects that document was
|
|
17
|
+
// written in, stops mattering here.
|
|
18
|
+
//
|
|
19
|
+
// `dereference` is used rather than `bundle` on purpose: the generator is asked
|
|
20
|
+
// to produce a body for one operation, and it should see that operation's
|
|
21
|
+
// shapes rather than a pointer into a components section it was never shown.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
export const METHODS = ['get', 'put', 'post', 'delete', 'patch', 'head', 'options'];
|
|
24
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
25
|
+
/** A `CliError` so an unreadable document exits 3 wherever it is raised. */
|
|
26
|
+
export class SpecError extends CliError {
|
|
27
|
+
constructor(message, hint) {
|
|
28
|
+
super(message, EXIT.invalid, hint);
|
|
29
|
+
this.name = 'SpecError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Loading
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
export async function loadSpecs(files) {
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
out.push(...(await loadSpec(file)));
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
export async function loadSpec(file) {
|
|
43
|
+
let doc;
|
|
44
|
+
try {
|
|
45
|
+
doc = (await SwaggerParser.dereference(file));
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
throw new SpecError(`${file}: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}`, 'the document must be a readable OpenAPI 3.x or Swagger 2.0 file');
|
|
49
|
+
}
|
|
50
|
+
const dialect = dialectOf(doc);
|
|
51
|
+
const prefix = dialect === 'swagger-2.0' ? (doc.basePath ?? '') : '';
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const [template, item] of Object.entries(doc.paths ?? {})) {
|
|
54
|
+
if (!isObject(item)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
// Path-level parameters apply to every method on it, and an operation
|
|
58
|
+
// may override one by repeating its name and location.
|
|
59
|
+
const shared = params(item.parameters, dialect);
|
|
60
|
+
for (const method of METHODS) {
|
|
61
|
+
const op = item[method];
|
|
62
|
+
if (!isObject(op)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const { path, fixed } = splitQuery(join(prefix, template));
|
|
66
|
+
const merged = override(override(shared, params(op.parameters, dialect)), pinned(fixed));
|
|
67
|
+
out.push(build({
|
|
68
|
+
source: file,
|
|
69
|
+
dialect,
|
|
70
|
+
method,
|
|
71
|
+
path,
|
|
72
|
+
fixed,
|
|
73
|
+
op,
|
|
74
|
+
params: merged,
|
|
75
|
+
body: requestBody(op, dialect),
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
function dialectOf(doc) {
|
|
82
|
+
if (doc.swagger?.startsWith('2.')) {
|
|
83
|
+
return 'swagger-2.0';
|
|
84
|
+
}
|
|
85
|
+
if (doc.openapi?.startsWith('3.1')) {
|
|
86
|
+
return 'openapi-3.1';
|
|
87
|
+
}
|
|
88
|
+
if (doc.openapi?.startsWith('3.')) {
|
|
89
|
+
return 'openapi-3.0';
|
|
90
|
+
}
|
|
91
|
+
throw new SpecError('neither `swagger: 2.0` nor `openapi: 3.x` is declared');
|
|
92
|
+
}
|
|
93
|
+
function join(prefix, template) {
|
|
94
|
+
const base = prefix.replace(/\/+$/, '');
|
|
95
|
+
return `${base}${template.startsWith('/') ? '' : '/'}${template}` || '/';
|
|
96
|
+
}
|
|
97
|
+
/** The path as it has to be called — what a person needs to see. */
|
|
98
|
+
export const called = (op) => op.fixed ? `${op.path}?${new URLSearchParams(op.fixed).toString()}` : op.path;
|
|
99
|
+
/**
|
|
100
|
+
* A path template has no query string, but some vendors write one
|
|
101
|
+
* anyway — `/x/{id}?action=retry` — because they need two `post`s under one
|
|
102
|
+
* path and the object model gives them one. Taken verbatim the key matches
|
|
103
|
+
* nothing, since a request path never contains a `?`. So it is read for what
|
|
104
|
+
* it means: the path is the part before the `?`, and each pair after it is a
|
|
105
|
+
* query value this operation insists on.
|
|
106
|
+
*/
|
|
107
|
+
function splitQuery(template) {
|
|
108
|
+
const cut = template.indexOf('?');
|
|
109
|
+
if (cut < 0) {
|
|
110
|
+
return { path: template };
|
|
111
|
+
}
|
|
112
|
+
const fixed = {};
|
|
113
|
+
for (const [name, value] of new URLSearchParams(template.slice(cut + 1))) {
|
|
114
|
+
fixed[name] = value;
|
|
115
|
+
}
|
|
116
|
+
const path = template.slice(0, cut) || '/';
|
|
117
|
+
return Object.keys(fixed).length > 0 ? { path, fixed } : { path };
|
|
118
|
+
}
|
|
119
|
+
/** A one-value enum, so the check, the page and the generator all see the rule. */
|
|
120
|
+
function pinned(fixed) {
|
|
121
|
+
return Object.entries(fixed ?? {}).map(([name, value]) => ({
|
|
122
|
+
name,
|
|
123
|
+
in: 'query',
|
|
124
|
+
required: true,
|
|
125
|
+
schema: { type: 'string', enum: [value] },
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
function build(b) {
|
|
129
|
+
const success = successOf(b.op, b.dialect);
|
|
130
|
+
const operationId = typeof b.op.operationId === 'string' && b.op.operationId
|
|
131
|
+
? b.op.operationId
|
|
132
|
+
: synthesizeId(b.method, b.path, b.fixed);
|
|
133
|
+
const operation = {
|
|
134
|
+
key: '',
|
|
135
|
+
source: b.source,
|
|
136
|
+
method: b.method,
|
|
137
|
+
path: b.path,
|
|
138
|
+
operationId,
|
|
139
|
+
summary: typeof b.op.summary === 'string' ? b.op.summary : undefined,
|
|
140
|
+
description: typeof b.op.description === 'string' ? b.op.description : undefined,
|
|
141
|
+
params: b.params,
|
|
142
|
+
fixed: b.fixed,
|
|
143
|
+
requestBody: b.body,
|
|
144
|
+
success,
|
|
145
|
+
paging: pagingOf(b.params, success.schema, b.body?.schema),
|
|
146
|
+
};
|
|
147
|
+
return { ...operation, key: keyOf(operation) };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Cache identity. Everything that changes what a generator must produce is in
|
|
151
|
+
* here and nothing else is — not the file it came from, not its summary, not
|
|
152
|
+
* the order of its keys. Editing a spec therefore yields a new key and the old
|
|
153
|
+
* artefact is simply never looked up again, so there is no invalidation step
|
|
154
|
+
* to get wrong.
|
|
155
|
+
*/
|
|
156
|
+
function keyOf(op) {
|
|
157
|
+
const shape = {
|
|
158
|
+
version: GENERATOR_RULES_VERSION,
|
|
159
|
+
method: op.method,
|
|
160
|
+
path: op.path,
|
|
161
|
+
operationId: op.operationId,
|
|
162
|
+
params: [...op.params]
|
|
163
|
+
.sort((a, b) => `${a.in}:${a.name}`.localeCompare(`${b.in}:${b.name}`))
|
|
164
|
+
.map((p) => [p.in, p.name, p.required, canonical(p.schema)]),
|
|
165
|
+
body: op.requestBody ? [op.requestBody.required, canonical(op.requestBody.schema)] : null,
|
|
166
|
+
success: [op.success.status, op.success.schema ? canonical(op.success.schema) : null],
|
|
167
|
+
// Derived from the two above, so it adds nothing to the identity — it is
|
|
168
|
+
// here to *change* it, once, for the operations whose generator now has
|
|
169
|
+
// a pagination rule to obey. Absent rather than null when there is no
|
|
170
|
+
// paging, so that everything else hashes to exactly what it did before
|
|
171
|
+
// and no one else is asked to rebuild.
|
|
172
|
+
...(op.paging ? { paging: canonical(op.paging) } : {}),
|
|
173
|
+
};
|
|
174
|
+
return createHash('sha256').update(JSON.stringify(shape)).digest('hex').slice(0, 16);
|
|
175
|
+
}
|
|
176
|
+
/** Key order is a formatting accident; it must not change a cache key. */
|
|
177
|
+
function canonical(value) {
|
|
178
|
+
if (Array.isArray(value)) {
|
|
179
|
+
return value.map(canonical);
|
|
180
|
+
}
|
|
181
|
+
if (isObject(value)) {
|
|
182
|
+
return Object.keys(value)
|
|
183
|
+
.sort()
|
|
184
|
+
.map((k) => [k, canonical(value[k])]);
|
|
185
|
+
}
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
function synthesizeId(method, path, fixed) {
|
|
189
|
+
const words = [...path.split('/'), ...Object.values(fixed ?? {})]
|
|
190
|
+
.filter(Boolean)
|
|
191
|
+
.map((s) => s.replace(/[{}]/g, '').replace(/[^A-Za-z0-9]+/g, '_'));
|
|
192
|
+
return [method, ...words].join('_');
|
|
193
|
+
}
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Parameters
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
function params(raw, dialect) {
|
|
198
|
+
if (!Array.isArray(raw)) {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
const out = [];
|
|
202
|
+
for (const entry of raw) {
|
|
203
|
+
if (!isObject(entry) || typeof entry.name !== 'string') {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const where = entry.in;
|
|
207
|
+
if (where !== 'path' && where !== 'query' && where !== 'header' && where !== 'cookie') {
|
|
208
|
+
continue; // `body` and `formData` are handled as a request body
|
|
209
|
+
}
|
|
210
|
+
out.push({
|
|
211
|
+
name: entry.name,
|
|
212
|
+
in: where,
|
|
213
|
+
required: where === 'path' ? true : entry.required === true,
|
|
214
|
+
schema: normalize(paramSchema(entry, dialect), dialect),
|
|
215
|
+
description: typeof entry.description === 'string' ? entry.description : undefined,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Swagger 2.0 puts a non-body parameter's type keywords directly on the
|
|
222
|
+
* parameter object; OpenAPI 3 moved them under `schema`. Both end up as a
|
|
223
|
+
* schema, so the difference is confined to this function.
|
|
224
|
+
*/
|
|
225
|
+
function paramSchema(entry, dialect) {
|
|
226
|
+
if (dialect !== 'swagger-2.0') {
|
|
227
|
+
return isObject(entry.schema) ? entry.schema : {};
|
|
228
|
+
}
|
|
229
|
+
const { name: _name, in: _in, required: _required, description: _description, collectionFormat: _collectionFormat, allowEmptyValue: _allowEmptyValue, ...rest } = entry;
|
|
230
|
+
return rest;
|
|
231
|
+
}
|
|
232
|
+
/** Operation-level parameters win over path-level ones with the same identity. */
|
|
233
|
+
function override(base, own) {
|
|
234
|
+
const merged = new Map(base.map((p) => [`${p.in}:${p.name}`, p]));
|
|
235
|
+
for (const p of own) {
|
|
236
|
+
merged.set(`${p.in}:${p.name}`, p);
|
|
237
|
+
}
|
|
238
|
+
return [...merged.values()];
|
|
239
|
+
}
|
|
240
|
+
function requestBody(op, dialect) {
|
|
241
|
+
if (dialect === 'swagger-2.0') {
|
|
242
|
+
const list = Array.isArray(op.parameters) ? op.parameters : [];
|
|
243
|
+
const body = list.find((p) => isObject(p) && p.in === 'body');
|
|
244
|
+
if (!body || !isObject(body.schema)) {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
return { required: body.required === true, schema: normalize(body.schema, dialect) };
|
|
248
|
+
}
|
|
249
|
+
const rb = op.requestBody;
|
|
250
|
+
if (!isObject(rb)) {
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
const schema = jsonContent(rb.content);
|
|
254
|
+
return schema
|
|
255
|
+
? { required: rb.required === true, schema: normalize(schema, dialect) }
|
|
256
|
+
: undefined;
|
|
257
|
+
}
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Responses
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
/**
|
|
262
|
+
* The response a call gets answered with: the lowest 2xx that carries a JSON
|
|
263
|
+
* body, or the lowest 2xx at all when none of them do. An operation whose
|
|
264
|
+
* success response has no JSON schema is answered `204` and never gets a
|
|
265
|
+
* generator — there is nothing for one to produce.
|
|
266
|
+
*/
|
|
267
|
+
function successOf(op, dialect) {
|
|
268
|
+
const responses = isObject(op.responses) ? op.responses : {};
|
|
269
|
+
const codes = Object.keys(responses)
|
|
270
|
+
.map(Number)
|
|
271
|
+
.filter((n) => Number.isInteger(n) && n >= 200 && n < 300)
|
|
272
|
+
.sort((a, b) => a - b);
|
|
273
|
+
for (const status of codes) {
|
|
274
|
+
const schema = responseSchema(responses[String(status)], dialect);
|
|
275
|
+
if (schema) {
|
|
276
|
+
return { status, schema: normalize(schema, dialect) };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return { status: codes[0] ?? 204 };
|
|
280
|
+
}
|
|
281
|
+
function responseSchema(response, dialect) {
|
|
282
|
+
if (!isObject(response)) {
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
if (dialect === 'swagger-2.0') {
|
|
286
|
+
return isObject(response.schema) ? response.schema : undefined;
|
|
287
|
+
}
|
|
288
|
+
return jsonContent(response.content);
|
|
289
|
+
}
|
|
290
|
+
/** The first JSON media type a content map offers, `+json` suffixes included. */
|
|
291
|
+
function jsonContent(content) {
|
|
292
|
+
if (!isObject(content)) {
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
for (const [type, entry] of Object.entries(content)) {
|
|
296
|
+
const base = type.split(';')[0].trim();
|
|
297
|
+
if ((base === 'application/json' || base.endsWith('+json')) && isObject(entry)) {
|
|
298
|
+
return isObject(entry.schema) ? entry.schema : undefined;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
//# sourceMappingURL=spec.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ErrorObject, type ValidateFunction } from 'ajv/dist/2020.js';
|
|
2
|
+
import type { Operation } from './spec.ts';
|
|
3
|
+
export interface Issue {
|
|
4
|
+
where: string;
|
|
5
|
+
message: string;
|
|
6
|
+
}
|
|
7
|
+
export declare class Checks {
|
|
8
|
+
#private;
|
|
9
|
+
constructor();
|
|
10
|
+
for(operation: Operation): Compiled;
|
|
11
|
+
}
|
|
12
|
+
export interface Compiled {
|
|
13
|
+
path: ValidateFunction;
|
|
14
|
+
query: ValidateFunction;
|
|
15
|
+
body?: ValidateFunction;
|
|
16
|
+
bodyRequired: boolean;
|
|
17
|
+
response?: ValidateFunction;
|
|
18
|
+
}
|
|
19
|
+
export declare function issues(prefix: string, errors: ErrorObject[] | null | undefined): Issue[];
|
|
20
|
+
export declare function describeIssues(list: readonly Issue[]): string;
|
|
21
|
+
//# sourceMappingURL=validate.d.ts.map
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Both packages are CommonJS. `ajv/dist/2020` has a real named export, so it is
|
|
2
|
+
// imported by name; `ajv-formats` sets `module.exports` to the plugin function
|
|
3
|
+
// but also declares named exports, which NodeNext models as a namespace with a
|
|
4
|
+
// synthetic default. The value is callable at runtime; only the type is wrong.
|
|
5
|
+
import addFormatsDefault from 'ajv-formats';
|
|
6
|
+
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
7
|
+
const addFormats = addFormatsDefault;
|
|
8
|
+
export class Checks {
|
|
9
|
+
// `logger: false` silences "unknown format … ignored". Real documents are
|
|
10
|
+
// full of vendor formats (`ip`, `mac-address`) and there is nothing to do
|
|
11
|
+
// about them; left on, one spec buries the request log in warnings.
|
|
12
|
+
#strict = new Ajv2020({
|
|
13
|
+
allErrors: true,
|
|
14
|
+
strict: false,
|
|
15
|
+
validateFormats: true,
|
|
16
|
+
logger: false,
|
|
17
|
+
});
|
|
18
|
+
#loose = new Ajv2020({
|
|
19
|
+
allErrors: true,
|
|
20
|
+
strict: false,
|
|
21
|
+
coerceTypes: true,
|
|
22
|
+
useDefaults: false,
|
|
23
|
+
validateFormats: true,
|
|
24
|
+
logger: false,
|
|
25
|
+
});
|
|
26
|
+
#cache = new Map();
|
|
27
|
+
constructor() {
|
|
28
|
+
addFormats(this.#strict);
|
|
29
|
+
addFormats(this.#loose);
|
|
30
|
+
}
|
|
31
|
+
for(operation) {
|
|
32
|
+
const hit = this.#cache.get(operation.key);
|
|
33
|
+
if (hit) {
|
|
34
|
+
return hit;
|
|
35
|
+
}
|
|
36
|
+
const compiled = {
|
|
37
|
+
path: this.#loose.compile(envelope(operation.params, 'path')),
|
|
38
|
+
query: this.#loose.compile(envelope(operation.params, 'query')),
|
|
39
|
+
body: operation.requestBody
|
|
40
|
+
? this.#strict.compile(operation.requestBody.schema)
|
|
41
|
+
: undefined,
|
|
42
|
+
bodyRequired: operation.requestBody?.required === true,
|
|
43
|
+
response: operation.success.schema
|
|
44
|
+
? this.#strict.compile(operation.success.schema)
|
|
45
|
+
: undefined,
|
|
46
|
+
};
|
|
47
|
+
this.#cache.set(operation.key, compiled);
|
|
48
|
+
return compiled;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Parameters are validated as one object rather than one at a time, so a
|
|
53
|
+
* request with three wrong values is answered once with all three.
|
|
54
|
+
*/
|
|
55
|
+
function envelope(params, where) {
|
|
56
|
+
const mine = params.filter((p) => p.in === where);
|
|
57
|
+
return {
|
|
58
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
59
|
+
type: 'object',
|
|
60
|
+
properties: Object.fromEntries(mine.map((p) => [p.name, stripped(p.schema)])),
|
|
61
|
+
required: mine.filter((p) => p.required).map((p) => p.name),
|
|
62
|
+
// A query string may carry anything; only what the spec names is checked.
|
|
63
|
+
additionalProperties: true,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** A subschema must not carry its own `$schema`; only the envelope may. */
|
|
67
|
+
function stripped(schema) {
|
|
68
|
+
const { $schema: _ignored, ...rest } = schema;
|
|
69
|
+
return rest;
|
|
70
|
+
}
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Reporting
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
export function issues(prefix, errors) {
|
|
75
|
+
return (errors ?? []).map((e) => ({
|
|
76
|
+
where: `${prefix}${e.instancePath}`,
|
|
77
|
+
message: e.message ?? 'is invalid',
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
export function describeIssues(list) {
|
|
81
|
+
return list.map((i) => `${i.where || '(root)'} ${i.message}`).join('; ');
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=validate.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@zenera/gym",
|
|
3
|
+
"version": "1.1.38",
|
|
4
|
+
"description": "Simulated API environments for agent training and evaluation, powered by OpenAPI and model-written generators.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"openapi",
|
|
7
|
+
"swagger",
|
|
8
|
+
"mock-server",
|
|
9
|
+
"llm",
|
|
10
|
+
"testing",
|
|
11
|
+
"agents",
|
|
12
|
+
"simulation",
|
|
13
|
+
"evaluation"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"author": "Andrey Ryabov",
|
|
20
|
+
"homepage": "https://github.com/zenera-ai/ZeneraNeo#readme",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/zenera-ai/ZeneraNeo.git",
|
|
24
|
+
"directory": "packages/gym"
|
|
25
|
+
},
|
|
26
|
+
"bugs": "https://github.com/zenera-ai/ZeneraNeo/issues",
|
|
27
|
+
"private": false,
|
|
28
|
+
"type": "module",
|
|
29
|
+
"exports": {
|
|
30
|
+
"./command": {
|
|
31
|
+
"types": "./dist/command.d.ts",
|
|
32
|
+
"default": "./dist/command.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"!dist/**/*.map"
|
|
38
|
+
],
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=24"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsc -b",
|
|
44
|
+
"clean": "tsc -b --clean && rm -rf dist",
|
|
45
|
+
"prepack": "tsc -b"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@apidevtools/swagger-parser": "^12.0.0",
|
|
49
|
+
"ajv": "^8.17.1",
|
|
50
|
+
"ajv-formats": "^3.0.1",
|
|
51
|
+
"@zenera/cli": "^1.1.38",
|
|
52
|
+
"@zenera/neo": "^1.1.38"
|
|
53
|
+
}
|
|
54
|
+
}
|