@llm4ts/shell 2.11.0 → 2.13.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/flows/epic-stories.js +25 -5
- package/flows/lib/epic-stories.js +76 -8
- package/kits/soap-ace/README.md +313 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/RUNBOOK.md +102 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/design/api-design.md +620 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/cercaConti.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/cercaMovimenti.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/confermaBonifico.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/dettaglioConto.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/inserisciBonifico.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/responses/revocaBonifico.xml +3 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/soapui/DemoBank-soapui-project.xml +39 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/DemoBank.wsdl +186 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/LegacyRpc.wsdl +29 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/banking.xsd +10 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/bonifici.xsd +107 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/common.xsd +93 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/conti.xsd +71 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/movimenti.xsd +55 -0
- package/kits/soap-ace/flows/lib/soap/Analysis.js +508 -0
- package/kits/soap-ace/flows/lib/soap/Auth.js +179 -0
- package/kits/soap-ace/flows/lib/soap/Call.js +186 -0
- package/kits/soap-ace/flows/lib/soap/CallPolicy.js +50 -0
- package/kits/soap-ace/flows/lib/soap/Catalog.js +157 -0
- package/kits/soap-ace/flows/lib/soap/Classification.js +254 -0
- package/kits/soap-ace/flows/lib/soap/Design.js +497 -0
- package/kits/soap-ace/flows/lib/soap/DesignProposal.js +204 -0
- package/kits/soap-ace/flows/lib/soap/Discover.js +139 -0
- package/kits/soap-ace/flows/lib/soap/Envelope.js +132 -0
- package/kits/soap-ace/flows/lib/soap/Epic.js +239 -0
- package/kits/soap-ace/flows/lib/soap/Instance.js +675 -0
- package/kits/soap-ace/flows/lib/soap/Mapping.js +130 -0
- package/kits/soap-ace/flows/lib/soap/Masking.js +508 -0
- package/kits/soap-ace/flows/lib/soap/OpenApi.js +288 -0
- package/kits/soap-ace/flows/lib/soap/Samples.js +287 -0
- package/kits/soap-ace/flows/lib/soap/Scenarios.js +132 -0
- package/kits/soap-ace/flows/lib/soap/SoapUiImport.js +200 -0
- package/kits/soap-ace/flows/lib/soap/Stub.js +34 -0
- package/kits/soap-ace/flows/lib/soap/Transport.js +167 -0
- package/kits/soap-ace/flows/lib/soap/Wsdl.js +533 -0
- package/kits/soap-ace/flows/lib/soap/Xml.js +453 -0
- package/kits/soap-ace/flows/lib/soap/Xsd.js +471 -0
- package/kits/soap-ace/flows/lib/soap/Yaml.js +309 -0
- package/kits/soap-ace/flows/soap-design.js +167 -0
- package/kits/soap-ace/flows/soap-discover.js +85 -0
- package/kits/soap-ace/flows/soap-epic.js +204 -0
- package/kits/soap-ace/flows/soap-sample.js +265 -0
- package/kits/soap-ace/packs/ace12-rest/api-style.md +63 -0
- package/kits/soap-ace/packs/ace12-rest/pack.md +18 -0
- package/kits/soap-ace/packs/ace12-rest/prompts/implement.md +8 -0
- package/kits/soap-ace/packs/ace12-rest/reviewers/contract.md +5 -0
- package/kits/soap-ace/patterns/PAT-ACE-001-esito-to-problem.md +8 -0
- package/kits/soap-ace/patterns/PAT-ACE-002-fault-to-problem.md +8 -0
- package/kits/soap-ace/patterns/PAT-ACE-003-amounts-and-enums.md +8 -0
- package/kits/soap-ace/patterns/PAT-ACE-004-pagination.md +7 -0
- package/kits/soap-ace/patterns/PAT-ACE-005-nil-and-absence.md +7 -0
- package/kits/soap-ace/scaffolds/ace12-rest-api/CONTRIBUTING.md +56 -0
- package/kits/soap-ace/scaffolds/ace12-rest-api/README.md +16 -0
- package/kits/soap-ace/scaffolds/ace12-rest-api/scripts/ace-gates-container.sh +8 -0
- package/kits/soap-ace/scaffolds/ace12-rest-api/scripts/ace-gates.sh +41 -0
- package/package.json +4 -4
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import { elementByName, operationByName } from "./Catalog.js";
|
|
4
|
+
import { readIfPresent } from "./Discover.js";
|
|
5
|
+
import { skeletonYaml, validateInstance } from "./Instance.js";
|
|
6
|
+
import { renderRequestFile, samplePaths, toSampleName } from "./Samples.js";
|
|
7
|
+
// Scenario sets: for one operation the reasoning seat proposes a handful of
|
|
8
|
+
// request bodies that together exercise it — the happy path, an empty
|
|
9
|
+
// result, a pagination boundary, a business fault, a schema edge. The model
|
|
10
|
+
// only drafts; each body is validated against the XSD and written as an
|
|
11
|
+
// ordinary request file (with its problems listed at the top), so the user
|
|
12
|
+
// reviews and edits before anything is called. Existing files are kept.
|
|
13
|
+
export class ProposedScenario extends Schema.Class("ProposedScenario")({
|
|
14
|
+
name: Schema.String,
|
|
15
|
+
purpose: Schema.String,
|
|
16
|
+
body: Schema.Json
|
|
17
|
+
}) {
|
|
18
|
+
}
|
|
19
|
+
export class ScenarioSet extends Schema.Class("ScenarioSet")({
|
|
20
|
+
scenarios: Schema.Array(ProposedScenario)
|
|
21
|
+
}) {
|
|
22
|
+
}
|
|
23
|
+
const scenarioJsonSchema = {
|
|
24
|
+
type: "object",
|
|
25
|
+
required: ["scenarios"],
|
|
26
|
+
properties: {
|
|
27
|
+
scenarios: {
|
|
28
|
+
type: "array",
|
|
29
|
+
items: {
|
|
30
|
+
type: "object",
|
|
31
|
+
required: ["name", "purpose", "body"],
|
|
32
|
+
properties: {
|
|
33
|
+
name: { type: "string", description: "kebab-case, e.g. happy-path" },
|
|
34
|
+
purpose: { type: "string", description: "what the scenario exercises, one sentence" },
|
|
35
|
+
body: { type: "object", description: "the request body, shaped like the skeleton" }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
/** JSON from a model → request values: every leaf a string, as XML text is. */
|
|
42
|
+
export const toYamlValue = (value) => {
|
|
43
|
+
if (value === null)
|
|
44
|
+
return null;
|
|
45
|
+
if (typeof value === "string")
|
|
46
|
+
return value;
|
|
47
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
48
|
+
return String(value);
|
|
49
|
+
if (Array.isArray(value))
|
|
50
|
+
return value.map(toYamlValue);
|
|
51
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, toYamlValue(item)]));
|
|
52
|
+
};
|
|
53
|
+
export const scenarioPrompt = (options) => {
|
|
54
|
+
const operation = operationByName(options.catalog, options.operation);
|
|
55
|
+
const input = operation === undefined ? undefined : elementByName(options.catalog, operation.input);
|
|
56
|
+
if (operation === undefined || input === undefined)
|
|
57
|
+
return undefined;
|
|
58
|
+
const output = operation.output === undefined ? undefined : elementByName(options.catalog, operation.output);
|
|
59
|
+
const seeds = [...options.seeds].map(([field, value]) => `- ${field}: ${value}`);
|
|
60
|
+
return [
|
|
61
|
+
`You are preparing sample requests to explore the SOAP operation ${operation.name} of a bank's backend,`,
|
|
62
|
+
"before an equivalent REST API is designed. Propose 3 to 6 scenarios that together show how it really",
|
|
63
|
+
"behaves. Cover, where the operation allows it: the happy path; an empty or not-found result; a",
|
|
64
|
+
"pagination boundary; one business error (an esito KO code or a SOAP fault); one schema edge (maximum",
|
|
65
|
+
"lengths, optional fields left out, optional fields all present).",
|
|
66
|
+
"",
|
|
67
|
+
`Operation: ${operation.name} (${options.operationClass})`,
|
|
68
|
+
...(operation.documentation === undefined ? [] : [`Documentation: ${operation.documentation}`]),
|
|
69
|
+
`Faults: ${operation.faults.map((fault) => fault.name).join(", ") || "none declared"}`,
|
|
70
|
+
"",
|
|
71
|
+
"The request body, as YAML with type notes (required fields filled, optional ones commented):",
|
|
72
|
+
"```yaml",
|
|
73
|
+
...skeletonYaml(options.catalog, input, 0),
|
|
74
|
+
"```",
|
|
75
|
+
...(output === undefined
|
|
76
|
+
? []
|
|
77
|
+
: [
|
|
78
|
+
"",
|
|
79
|
+
"The response body shape:",
|
|
80
|
+
"```yaml",
|
|
81
|
+
...skeletonYaml(options.catalog, output, 0, { maxDepth: 3 }),
|
|
82
|
+
"```"
|
|
83
|
+
]),
|
|
84
|
+
"",
|
|
85
|
+
"Rules:",
|
|
86
|
+
"- Use only synthetic data. Never invent a real person's name, tax code, or account.",
|
|
87
|
+
"- Reuse these identifiers seen in earlier samples wherever an identifier is needed; they are masked",
|
|
88
|
+
" test values the service is likely to know:",
|
|
89
|
+
...(seeds.length === 0
|
|
90
|
+
? [" (none yet: use the examples in the skeleton)"]
|
|
91
|
+
: seeds.map((seed) => ` ${seed}`)),
|
|
92
|
+
"- Every value is a string. Respect the type notes (patterns, lengths, enumerations, digits).",
|
|
93
|
+
"- Omit optional fields you do not need; never add fields that are not in the skeleton.",
|
|
94
|
+
"- Names are kebab-case and unique; the purpose says what the scenario should reveal.",
|
|
95
|
+
"",
|
|
96
|
+
'Reply with JSON: {"scenarios": [{"name": "...", "purpose": "...", "body": {...}}]}.'
|
|
97
|
+
].join("\n");
|
|
98
|
+
};
|
|
99
|
+
export const proposeScenarios = (options) => Effect.gen(function* () {
|
|
100
|
+
const prompt = scenarioPrompt(options);
|
|
101
|
+
const operation = operationByName(options.catalog, options.operation);
|
|
102
|
+
const input = operation === undefined ? undefined : elementByName(options.catalog, operation.input);
|
|
103
|
+
if (prompt === undefined || input === undefined)
|
|
104
|
+
return [];
|
|
105
|
+
const proposal = yield* options.reasoning.executeStructured(prompt, ScenarioSet, scenarioJsonSchema);
|
|
106
|
+
const written = [];
|
|
107
|
+
const names = new Set();
|
|
108
|
+
for (const scenario of proposal.scenarios) {
|
|
109
|
+
let name = toSampleName(scenario.name);
|
|
110
|
+
for (let suffix = 2; names.has(name); suffix++)
|
|
111
|
+
name = `${toSampleName(scenario.name).slice(0, 58)}-${suffix}`;
|
|
112
|
+
names.add(name);
|
|
113
|
+
const paths = samplePaths(options.service, options.operation, name);
|
|
114
|
+
const exists = (yield* readIfPresent(options.workspace, paths.yaml)) !== undefined ||
|
|
115
|
+
(yield* readIfPresent(options.workspace, paths.xml)) !== undefined;
|
|
116
|
+
const body = toYamlValue(scenario.body);
|
|
117
|
+
const issues = validateInstance(options.catalog, input, body);
|
|
118
|
+
if (exists) {
|
|
119
|
+
written.push({ name, path: paths.yaml, issues, kept: true });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
yield* options.workspace.write(paths.yaml, renderRequestFile({
|
|
123
|
+
catalog: options.catalog,
|
|
124
|
+
operation: options.operation,
|
|
125
|
+
purpose: scenario.purpose,
|
|
126
|
+
body,
|
|
127
|
+
issues
|
|
128
|
+
}));
|
|
129
|
+
written.push({ name, path: paths.yaml, issues, kept: false });
|
|
130
|
+
}
|
|
131
|
+
return written;
|
|
132
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import { elementByName, operationByName } from "./Catalog.js";
|
|
3
|
+
import { readIfPresent } from "./Discover.js";
|
|
4
|
+
import { readEnvelope, stripSecurity } from "./Envelope.js";
|
|
5
|
+
import { instanceFromXml } from "./Instance.js";
|
|
6
|
+
import { kindsFromCatalog, maskDocument, maskText, MaskingReport, mergeReports } from "./Masking.js";
|
|
7
|
+
import { Exchange, issueRecords, RecordedResponse, renderRequestFile, samplePaths, toSampleName, writeExchange } from "./Samples.js";
|
|
8
|
+
import { attribute, elements, parseXml, renderXml, textOf } from "./Xml.js";
|
|
9
|
+
// SoapUI / ReadyAPI projects are where bank teams keep their working
|
|
10
|
+
// requests. Import turns each saved request (interface calls and test
|
|
11
|
+
// steps) into a request file and each mock response into a recorded
|
|
12
|
+
// exchange. Everything passes through the masker first: a project copied
|
|
13
|
+
// from a shared drive often holds real customer data. Stored credentials
|
|
14
|
+
// (`con:credentials`, WS-S passwords) are counted and never read.
|
|
15
|
+
export const SoapUiNamespace = "http://eviware.com/soapui/config";
|
|
16
|
+
const con = (element, local) => elements(element).filter((child) => child.name.namespace === SoapUiNamespace && child.name.local === local);
|
|
17
|
+
const conText = (element, local) => {
|
|
18
|
+
const found = con(element, local)[0];
|
|
19
|
+
return found === undefined ? undefined : textOf(found);
|
|
20
|
+
};
|
|
21
|
+
const hasCredentials = (element) => {
|
|
22
|
+
const credentials = con(element, "credentials")[0];
|
|
23
|
+
if (credentials === undefined)
|
|
24
|
+
return false;
|
|
25
|
+
return elements(credentials).some((child) => textOf(child).trim() !== "" && /password|username|token/i.test(child.name.local));
|
|
26
|
+
};
|
|
27
|
+
/** Every saved request and mock response in a project, with its operation. */
|
|
28
|
+
export const findMessages = (project) => {
|
|
29
|
+
const messages = [];
|
|
30
|
+
let credentials = 0;
|
|
31
|
+
for (const iface of con(project, "interface")) {
|
|
32
|
+
for (const operation of con(iface, "operation")) {
|
|
33
|
+
const name = attribute(operation, "name") ?? "";
|
|
34
|
+
for (const call of con(operation, "call")) {
|
|
35
|
+
if (hasCredentials(call))
|
|
36
|
+
credentials++;
|
|
37
|
+
const envelope = conText(call, "request");
|
|
38
|
+
if (envelope !== undefined && envelope.trim() !== "") {
|
|
39
|
+
messages.push({
|
|
40
|
+
operation: name,
|
|
41
|
+
label: attribute(call, "name") ?? "call",
|
|
42
|
+
envelope,
|
|
43
|
+
kind: "request"
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const walkSuites = (element, path) => {
|
|
50
|
+
for (const child of elements(element)) {
|
|
51
|
+
if (child.name.namespace !== SoapUiNamespace)
|
|
52
|
+
continue;
|
|
53
|
+
if (child.name.local === "testSuite" || child.name.local === "testCase") {
|
|
54
|
+
walkSuites(child, [...path, attribute(child, "name") ?? child.name.local]);
|
|
55
|
+
}
|
|
56
|
+
else if (child.name.local === "testStep" && attribute(child, "type") === "request") {
|
|
57
|
+
const config = con(child, "config")[0];
|
|
58
|
+
const operation = config === undefined ? undefined : conText(config, "operation");
|
|
59
|
+
const request = config === undefined ? undefined : con(config, "request")[0];
|
|
60
|
+
if (request !== undefined && hasCredentials(request))
|
|
61
|
+
credentials++;
|
|
62
|
+
const envelope = request === undefined ? undefined : conText(request, "request");
|
|
63
|
+
if (operation !== undefined && envelope !== undefined && envelope.trim() !== "") {
|
|
64
|
+
messages.push({
|
|
65
|
+
operation: operation.trim(),
|
|
66
|
+
label: [...path, attribute(child, "name") ?? "step"].join(" "),
|
|
67
|
+
envelope,
|
|
68
|
+
kind: "request"
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
walkSuites(project, []);
|
|
75
|
+
for (const mock of con(project, "mockService")) {
|
|
76
|
+
for (const operation of con(mock, "mockOperation")) {
|
|
77
|
+
const name = attribute(operation, "operation") ?? attribute(operation, "name") ?? "";
|
|
78
|
+
for (const response of con(operation, "response")) {
|
|
79
|
+
const envelope = conText(response, "responseContent");
|
|
80
|
+
if (envelope !== undefined && envelope.trim() !== "") {
|
|
81
|
+
messages.push({
|
|
82
|
+
operation: name,
|
|
83
|
+
label: `mock ${attribute(response, "name") ?? "response"}`,
|
|
84
|
+
envelope,
|
|
85
|
+
kind: "response"
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { messages, credentials };
|
|
92
|
+
};
|
|
93
|
+
export const importSoapUiProject = (options) => Effect.gen(function* () {
|
|
94
|
+
const { catalog, workspace, service } = options;
|
|
95
|
+
const project = yield* parseXml(options.project, { source: "SoapUI project" });
|
|
96
|
+
const { messages, credentials } = findMessages(project);
|
|
97
|
+
const maskOptions = {
|
|
98
|
+
key: options.key,
|
|
99
|
+
typeKinds: kindsFromCatalog(catalog),
|
|
100
|
+
...(options.overrides === undefined ? {} : { overrides: options.overrides })
|
|
101
|
+
};
|
|
102
|
+
const requests = [];
|
|
103
|
+
const responses = [];
|
|
104
|
+
const skipped = [];
|
|
105
|
+
const reports = [];
|
|
106
|
+
const used = new Set();
|
|
107
|
+
for (const found of messages) {
|
|
108
|
+
// SoapUI labels are free text people type ("Conti di Mario Rossi …"):
|
|
109
|
+
// they reach file names and headers, so they are masked like values.
|
|
110
|
+
const message = { ...found, label: maskText(options.key, found.label).text };
|
|
111
|
+
const operation = operationByName(catalog, message.operation);
|
|
112
|
+
if (operation === undefined) {
|
|
113
|
+
skipped.push(`${message.label}: operation ${message.operation} is not in the catalog`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
// SoapUI keeps property expansions (${#Project#iban}) inside the XML
|
|
117
|
+
// text; they parse fine and are reported by validation.
|
|
118
|
+
const parsed = yield* parseXml(message.envelope, { source: message.label }).pipe(Effect.option);
|
|
119
|
+
if (parsed._tag === "None") {
|
|
120
|
+
skipped.push(`${message.operation} ${message.label}: not well-formed XML`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const masked = maskDocument(stripSecurity(parsed.value), maskOptions);
|
|
124
|
+
reports.push(masked.report);
|
|
125
|
+
const envelope = yield* readEnvelope(masked.document).pipe(Effect.option);
|
|
126
|
+
const payload = envelope._tag === "Some" ? envelope.value.payload : undefined;
|
|
127
|
+
let base = toSampleName(`soapui-${message.label}`);
|
|
128
|
+
for (let suffix = 2; used.has(`${message.operation}/${base}`); suffix++) {
|
|
129
|
+
base = `${toSampleName(`soapui-${message.label}`).slice(0, 58)}-${suffix}`;
|
|
130
|
+
}
|
|
131
|
+
used.add(`${message.operation}/${base}`);
|
|
132
|
+
const paths = samplePaths(service, message.operation, base);
|
|
133
|
+
if (message.kind === "request") {
|
|
134
|
+
const input = elementByName(catalog, operation.input);
|
|
135
|
+
if (input === undefined || payload === undefined) {
|
|
136
|
+
skipped.push(`${message.operation} ${message.label}: no request body`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if ((yield* readIfPresent(workspace, paths.yaml)) !== undefined ||
|
|
140
|
+
(yield* readIfPresent(workspace, paths.xml)) !== undefined) {
|
|
141
|
+
skipped.push(`${message.operation}/${base}: a request file already exists; kept it`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const read = instanceFromXml(catalog, input, payload);
|
|
145
|
+
const placeholders = read.issues.length > 0 && JSON.stringify(read.value).includes('"?"');
|
|
146
|
+
const issues = placeholders
|
|
147
|
+
? [
|
|
148
|
+
{ path: "(root)", detail: "SoapUI placeholders (?) are still in this request" },
|
|
149
|
+
...read.issues
|
|
150
|
+
]
|
|
151
|
+
: read.issues;
|
|
152
|
+
yield* workspace.write(paths.yaml, renderRequestFile({
|
|
153
|
+
catalog,
|
|
154
|
+
operation: message.operation,
|
|
155
|
+
purpose: `imported from SoapUI: ${message.label}`,
|
|
156
|
+
body: read.value,
|
|
157
|
+
issues
|
|
158
|
+
}));
|
|
159
|
+
requests.push({ operation: message.operation, name: base, path: paths.yaml, issues });
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
const output = operation.output === undefined ? undefined : elementByName(catalog, operation.output);
|
|
163
|
+
const responseIssues = envelope._tag === "Some" &&
|
|
164
|
+
envelope.value.fault === undefined &&
|
|
165
|
+
output !== undefined &&
|
|
166
|
+
payload !== undefined
|
|
167
|
+
? instanceFromXml(catalog, output, payload).issues
|
|
168
|
+
: [];
|
|
169
|
+
const exchange = new Exchange({
|
|
170
|
+
version: 1,
|
|
171
|
+
operation: message.operation,
|
|
172
|
+
name: base,
|
|
173
|
+
purpose: `SoapUI ${message.label}`,
|
|
174
|
+
provenance: "soapui",
|
|
175
|
+
recordedAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
176
|
+
response: new RecordedResponse({
|
|
177
|
+
status: 200,
|
|
178
|
+
headers: {},
|
|
179
|
+
envelope: renderXml(masked.document),
|
|
180
|
+
elapsedMs: 0
|
|
181
|
+
}),
|
|
182
|
+
...(envelope._tag === "Some" && envelope.value.fault !== undefined
|
|
183
|
+
? { fault: envelope.value.fault }
|
|
184
|
+
: {}),
|
|
185
|
+
requestIssues: [],
|
|
186
|
+
responseIssues: issueRecords(responseIssues),
|
|
187
|
+
masking: masked.report
|
|
188
|
+
});
|
|
189
|
+
const path = yield* writeExchange(workspace, service, exchange);
|
|
190
|
+
responses.push({ operation: message.operation, name: base, path });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
requests,
|
|
195
|
+
responses,
|
|
196
|
+
skipped,
|
|
197
|
+
credentialsIgnored: credentials,
|
|
198
|
+
masking: reports.length === 0 ? new MaskingReport({ entries: [], kept: [] }) : mergeReports(reports)
|
|
199
|
+
};
|
|
200
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import { Soap11Namespace } from "./Envelope.js";
|
|
5
|
+
// A backend stand-in: answers each call with `<directory>/<operation>.xml`,
|
|
6
|
+
// the operation found from the SOAPAction (SOAP 1.1 header or the SOAP 1.2
|
|
7
|
+
// content-type `action`). For rehearsals without a reachable service
|
|
8
|
+
// (LLM4TS_SOAP_STUB) and for the ACE tests' recorded backend.
|
|
9
|
+
export const soapActionOf = (request) => {
|
|
10
|
+
const header = request.headers["soapaction"];
|
|
11
|
+
if (header !== undefined)
|
|
12
|
+
return header.replace(/^"|"$/g, "");
|
|
13
|
+
return /action="([^"]*)"/.exec(request.headers["content-type"] ?? "")?.[1] ?? "";
|
|
14
|
+
};
|
|
15
|
+
const stubFault = (reason) => ({
|
|
16
|
+
status: 500,
|
|
17
|
+
headers: { "content-type": "text/xml; charset=utf-8" },
|
|
18
|
+
body: new TextEncoder().encode(`<s:Envelope xmlns:s="${Soap11Namespace}"><s:Body><s:Fault><faultcode>s:Server</faultcode><faultstring>${reason}</faultstring></s:Fault></s:Body></s:Envelope>`),
|
|
19
|
+
elapsedMs: 1
|
|
20
|
+
});
|
|
21
|
+
export const makeDirectoryStubTransport = (directory, catalog) => ({
|
|
22
|
+
send: (request) => {
|
|
23
|
+
const action = soapActionOf(request);
|
|
24
|
+
const operation = catalog.operations.find((candidate) => candidate.soapAction === action);
|
|
25
|
+
if (operation === undefined)
|
|
26
|
+
return Effect.succeed(stubFault(`stub: no operation for action ${action}`));
|
|
27
|
+
return Effect.tryPromise(() => readFile(join(directory, `${operation.name}.xml`))).pipe(Effect.map((bytes) => ({
|
|
28
|
+
status: 200,
|
|
29
|
+
headers: { "content-type": "text/xml; charset=utf-8" },
|
|
30
|
+
body: new Uint8Array(bytes),
|
|
31
|
+
elapsedMs: 1
|
|
32
|
+
})), Effect.orElseSucceed(() => stubFault(`stub: no ${operation.name}.xml`)));
|
|
33
|
+
}
|
|
34
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import * as http from "node:http";
|
|
2
|
+
import * as https from "node:https";
|
|
3
|
+
import * as Context from "effect/Context";
|
|
4
|
+
import * as Duration from "effect/Duration";
|
|
5
|
+
import * as Effect from "effect/Effect";
|
|
6
|
+
import * as Redacted from "effect/Redacted";
|
|
7
|
+
import * as Ref from "effect/Ref";
|
|
8
|
+
import * as Schema from "effect/Schema";
|
|
9
|
+
import { DocumentLoadError, makeFileDocumentLoader } from "./Wsdl.js";
|
|
10
|
+
export class TransportError extends Schema.TaggedError()("TransportError", {
|
|
11
|
+
url: Schema.String,
|
|
12
|
+
reason: Schema.Literals(["scheme", "network", "tls", "timeout", "aborted"]),
|
|
13
|
+
detail: Schema.String
|
|
14
|
+
}) {
|
|
15
|
+
get message() {
|
|
16
|
+
return `${this.url}: ${this.reason}: ${this.detail}`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export class SoapTransport extends Context.Service()("@llm4ts/kits/soap-ace/SoapTransport") {
|
|
20
|
+
}
|
|
21
|
+
/** Strip query string and credentials from a URL before it lands in an error or a file. */
|
|
22
|
+
export const displayUrl = (raw) => {
|
|
23
|
+
try {
|
|
24
|
+
const url = new URL(raw);
|
|
25
|
+
return `${url.protocol}//${url.host}${url.pathname}`;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return "(invalid url)";
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const errorCode = (cause) => typeof cause === "object" && cause !== null && "code" in cause && typeof cause.code === "string"
|
|
32
|
+
? cause.code
|
|
33
|
+
: "request failed";
|
|
34
|
+
const tlsCodes = /CERT|SSL|TLS|SELF_SIGNED|UNABLE_TO|DEPTH_ZERO|ERR_OSSL/;
|
|
35
|
+
const flatHeaders = (headers) => Object.fromEntries(Object.entries(headers).flatMap(([name, value]) => value === undefined ? [] : [[name, Array.isArray(value) ? value.join(", ") : value]]));
|
|
36
|
+
const bufferOf = (secret) => secret === undefined ? undefined : Buffer.from(Redacted.value(secret));
|
|
37
|
+
export const makeNodeSoapTransport = () => ({
|
|
38
|
+
send: (request) => {
|
|
39
|
+
const shown = displayUrl(request.url);
|
|
40
|
+
let url;
|
|
41
|
+
try {
|
|
42
|
+
url = new URL(request.url);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return Effect.fail(new TransportError({ url: shown, reason: "scheme", detail: "invalid URL" }));
|
|
46
|
+
}
|
|
47
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
48
|
+
return Effect.fail(new TransportError({
|
|
49
|
+
url: shown,
|
|
50
|
+
reason: "scheme",
|
|
51
|
+
detail: `${url.protocol} is not http(s)`
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
if (url.protocol === "http:" && request.tls !== undefined) {
|
|
55
|
+
return Effect.fail(new TransportError({
|
|
56
|
+
url: shown,
|
|
57
|
+
reason: "scheme",
|
|
58
|
+
detail: "client TLS needs an https URL"
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
const headers = { ...request.headers };
|
|
62
|
+
for (const [name, value] of request.secretHeaders)
|
|
63
|
+
headers[name] = Redacted.value(value);
|
|
64
|
+
const body = request.body === undefined ? undefined : Buffer.from(Redacted.value(request.body), "utf8");
|
|
65
|
+
if (body !== undefined)
|
|
66
|
+
headers["content-length"] = String(body.length);
|
|
67
|
+
return Effect.tryPromise({
|
|
68
|
+
try: (signal) => new Promise((resolve, reject) => {
|
|
69
|
+
const started = performance.now();
|
|
70
|
+
const tls = request.tls;
|
|
71
|
+
const options = {
|
|
72
|
+
method: request.method,
|
|
73
|
+
headers,
|
|
74
|
+
signal,
|
|
75
|
+
...(tls === undefined
|
|
76
|
+
? {}
|
|
77
|
+
: {
|
|
78
|
+
cert: bufferOf(tls.cert),
|
|
79
|
+
key: bufferOf(tls.key),
|
|
80
|
+
pfx: bufferOf(tls.pfx),
|
|
81
|
+
ca: bufferOf(tls.ca),
|
|
82
|
+
passphrase: tls.passphrase === undefined ? undefined : Redacted.value(tls.passphrase)
|
|
83
|
+
})
|
|
84
|
+
};
|
|
85
|
+
const handler = (response) => {
|
|
86
|
+
const chunks = [];
|
|
87
|
+
response.on("data", (chunk) => chunks.push(chunk));
|
|
88
|
+
response.on("error", reject);
|
|
89
|
+
response.on("end", () => resolve({
|
|
90
|
+
status: response.statusCode ?? 0,
|
|
91
|
+
headers: flatHeaders(response.headers),
|
|
92
|
+
body: new Uint8Array(Buffer.concat(chunks)),
|
|
93
|
+
elapsedMs: Math.round(performance.now() - started)
|
|
94
|
+
}));
|
|
95
|
+
};
|
|
96
|
+
const outgoing = url.protocol === "https:"
|
|
97
|
+
? https.request(url, options, handler)
|
|
98
|
+
: http.request(url, options, handler);
|
|
99
|
+
outgoing.on("error", reject);
|
|
100
|
+
if (body !== undefined)
|
|
101
|
+
outgoing.write(body);
|
|
102
|
+
outgoing.end();
|
|
103
|
+
}),
|
|
104
|
+
catch: (cause) => {
|
|
105
|
+
const code = errorCode(cause);
|
|
106
|
+
return new TransportError({
|
|
107
|
+
url: shown,
|
|
108
|
+
reason: code === "ABORT_ERR" ? "aborted" : tlsCodes.test(code) ? "tls" : "network",
|
|
109
|
+
detail: code
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}).pipe(Effect.timeoutOrElse({
|
|
113
|
+
duration: request.timeout,
|
|
114
|
+
orElse: () => Effect.fail(new TransportError({
|
|
115
|
+
url: shown,
|
|
116
|
+
reason: "timeout",
|
|
117
|
+
detail: `no response within ${Duration.format(request.timeout)}`
|
|
118
|
+
}))
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
/**
|
|
123
|
+
* A scripted transport for tests: each request goes to `respond`, and every
|
|
124
|
+
* request is recorded (secrets still redacted) for assertions.
|
|
125
|
+
*/
|
|
126
|
+
export const makeFakeSoapTransport = (respond) => Effect.map(Ref.make([]), (log) => ({
|
|
127
|
+
transport: {
|
|
128
|
+
send: (request) => Effect.andThen(Ref.update(log, (all) => [...all, request]), respond(request))
|
|
129
|
+
},
|
|
130
|
+
requests: Ref.get(log)
|
|
131
|
+
}));
|
|
132
|
+
export const xmlResponse = (xml, status = 200) => ({
|
|
133
|
+
status,
|
|
134
|
+
headers: { "content-type": "text/xml; charset=utf-8" },
|
|
135
|
+
body: new TextEncoder().encode(xml),
|
|
136
|
+
elapsedMs: 5
|
|
137
|
+
});
|
|
138
|
+
const isUrl = (location) => /^https?:\/\//i.test(location);
|
|
139
|
+
/**
|
|
140
|
+
* The document loader for discovery: local paths from disk, http(s) URLs
|
|
141
|
+
* through the transport with the profile's fetch-side auth and TLS.
|
|
142
|
+
*/
|
|
143
|
+
export const makeTransportDocumentLoader = (transport, side, timeout = Duration.seconds(30)) => {
|
|
144
|
+
const files = makeFileDocumentLoader();
|
|
145
|
+
return {
|
|
146
|
+
load: (location) => !isUrl(location)
|
|
147
|
+
? files.load(location)
|
|
148
|
+
: transport
|
|
149
|
+
.send({
|
|
150
|
+
method: "GET",
|
|
151
|
+
url: location,
|
|
152
|
+
headers: { accept: "text/xml, application/xml, */*" },
|
|
153
|
+
secretHeaders: side.headers,
|
|
154
|
+
...(side.tls === undefined ? {} : { tls: side.tls }),
|
|
155
|
+
timeout
|
|
156
|
+
})
|
|
157
|
+
.pipe(Effect.mapError((error) => new DocumentLoadError({
|
|
158
|
+
location: displayUrl(location),
|
|
159
|
+
detail: `${error.reason}: ${error.detail}`
|
|
160
|
+
})), Effect.flatMap((response) => response.status >= 200 && response.status < 300
|
|
161
|
+
? Effect.succeed(response.body)
|
|
162
|
+
: Effect.fail(new DocumentLoadError({
|
|
163
|
+
location: displayUrl(location),
|
|
164
|
+
detail: `HTTP ${response.status}`
|
|
165
|
+
}))))
|
|
166
|
+
};
|
|
167
|
+
};
|