@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,288 @@
|
|
|
1
|
+
import { ErrorMapping } from "./Design.js";
|
|
2
|
+
const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
|
|
3
|
+
const withDefined = (entries) => Object.fromEntries(entries.filter((entry) => entry[1] !== undefined));
|
|
4
|
+
/**
|
|
5
|
+
* One property. OpenAPI 3.1 is JSON Schema: `type: [t, "null"]`, and keys
|
|
6
|
+
* beside `$ref` are honoured. OpenAPI 3.0 (what IBM ACE 12 imports) needs
|
|
7
|
+
* `nullable: true`, and a `$ref` with siblings wrapped in `allOf`.
|
|
8
|
+
*/
|
|
9
|
+
const propertySchema = (property, dialect) => {
|
|
10
|
+
const common = withDefined([
|
|
11
|
+
["description", property.description],
|
|
12
|
+
["x-soap-source", property.source],
|
|
13
|
+
["x-derivation", property.derivation],
|
|
14
|
+
["x-soap-enum-map", property.enumMap === undefined ? undefined : { ...property.enumMap }]
|
|
15
|
+
]);
|
|
16
|
+
const nullable = property.nullable === true;
|
|
17
|
+
if (property.type === "object" && property.ref !== undefined) {
|
|
18
|
+
if (dialect === "3.0") {
|
|
19
|
+
return Object.keys(common).length === 0 && !nullable
|
|
20
|
+
? ref(property.ref)
|
|
21
|
+
: { allOf: [ref(property.ref)], ...(nullable ? { nullable: true } : {}), ...common };
|
|
22
|
+
}
|
|
23
|
+
return nullable
|
|
24
|
+
? { oneOf: [ref(property.ref), { type: "null" }], ...common }
|
|
25
|
+
: { ...ref(property.ref), ...common };
|
|
26
|
+
}
|
|
27
|
+
const typeOf = (type) => dialect === "3.0"
|
|
28
|
+
? { type, ...(nullable ? { nullable: true } : {}) }
|
|
29
|
+
: { type: nullable ? [type, "null"] : type };
|
|
30
|
+
if (property.type === "array") {
|
|
31
|
+
return {
|
|
32
|
+
...typeOf("array"),
|
|
33
|
+
items: property.ref !== undefined ? ref(property.ref) : { type: property.items ?? "string" },
|
|
34
|
+
...common
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
...typeOf(property.type),
|
|
39
|
+
...withDefined([
|
|
40
|
+
["format", property.format],
|
|
41
|
+
["enum", property.enum === undefined ? undefined : [...property.enum]]
|
|
42
|
+
]),
|
|
43
|
+
...common
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
const modelSchema = (model, dialect) => ({
|
|
47
|
+
type: "object",
|
|
48
|
+
...withDefined([["description", model.description]]),
|
|
49
|
+
...(model.properties.some((property) => property.required)
|
|
50
|
+
? {
|
|
51
|
+
required: model.properties
|
|
52
|
+
.filter((property) => property.required)
|
|
53
|
+
.map((property) => property.name)
|
|
54
|
+
}
|
|
55
|
+
: {}),
|
|
56
|
+
properties: Object.fromEntries(model.properties.map((property) => [property.name, propertySchema(property, dialect)])),
|
|
57
|
+
additionalProperties: false,
|
|
58
|
+
...withDefined([["x-soap-source-type", model.sourceType]])
|
|
59
|
+
});
|
|
60
|
+
const problemSchema = {
|
|
61
|
+
type: "object",
|
|
62
|
+
description: "RFC 9457 problem details; `code` is the stable, machine-readable error code.",
|
|
63
|
+
required: ["type", "title", "status", "code"],
|
|
64
|
+
properties: {
|
|
65
|
+
type: { type: "string", format: "uri-reference" },
|
|
66
|
+
title: { type: "string" },
|
|
67
|
+
status: { type: "integer", format: "int32" },
|
|
68
|
+
detail: { type: "string" },
|
|
69
|
+
instance: { type: "string", format: "uri-reference" },
|
|
70
|
+
code: { type: "string" }
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const pageSchema = {
|
|
74
|
+
type: "object",
|
|
75
|
+
required: ["page", "size"],
|
|
76
|
+
properties: {
|
|
77
|
+
page: { type: "integer", format: "int32", minimum: 1 },
|
|
78
|
+
size: { type: "integer", format: "int32", minimum: 1 },
|
|
79
|
+
totalItems: { type: "integer", format: "int64" },
|
|
80
|
+
hasNext: { type: "boolean" }
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const listName = (response) => `${response.model ?? "Item"}${response.paged === true ? "Page" : "List"}`;
|
|
84
|
+
const parameterObject = (parameter) => ({
|
|
85
|
+
name: parameter.name,
|
|
86
|
+
in: parameter.in,
|
|
87
|
+
required: parameter.required,
|
|
88
|
+
...withDefined([["description", parameter.description]]),
|
|
89
|
+
schema: {
|
|
90
|
+
type: parameter.type,
|
|
91
|
+
...withDefined([
|
|
92
|
+
["format", parameter.format],
|
|
93
|
+
["enum", parameter.enum === undefined ? undefined : [...parameter.enum]]
|
|
94
|
+
])
|
|
95
|
+
},
|
|
96
|
+
...withDefined([
|
|
97
|
+
["x-soap-source", parameter.source],
|
|
98
|
+
["x-derivation", parameter.derivation],
|
|
99
|
+
["x-soap-enum-map", parameter.enumMap === undefined ? undefined : { ...parameter.enumMap }]
|
|
100
|
+
])
|
|
101
|
+
});
|
|
102
|
+
const successObject = (response, created) => {
|
|
103
|
+
const schema = response.model === undefined
|
|
104
|
+
? undefined
|
|
105
|
+
: response.list === true
|
|
106
|
+
? ref(listName(response))
|
|
107
|
+
: ref(response.model);
|
|
108
|
+
return {
|
|
109
|
+
description: response.description,
|
|
110
|
+
...(created && response.status === 201
|
|
111
|
+
? {
|
|
112
|
+
headers: {
|
|
113
|
+
Location: {
|
|
114
|
+
description: "URL of the created resource",
|
|
115
|
+
schema: { type: "string", format: "uri-reference" }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
: {}),
|
|
120
|
+
...(schema === undefined ? {} : { content: { "application/json": { schema } } }),
|
|
121
|
+
...withDefined([["x-soap-source", response.source]])
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
const errorResponses = (errors) => {
|
|
125
|
+
const byStatus = new Map();
|
|
126
|
+
for (const mapping of errors)
|
|
127
|
+
byStatus.set(mapping.status, [...(byStatus.get(mapping.status) ?? []), mapping]);
|
|
128
|
+
if (!byStatus.has(502)) {
|
|
129
|
+
byStatus.set(502, [
|
|
130
|
+
new ErrorMapping({
|
|
131
|
+
status: 502,
|
|
132
|
+
code: "backend-error",
|
|
133
|
+
title: "The backend service returned an unmapped error",
|
|
134
|
+
from: ["transport"]
|
|
135
|
+
})
|
|
136
|
+
]);
|
|
137
|
+
}
|
|
138
|
+
return Object.fromEntries([...byStatus]
|
|
139
|
+
.sort((left, right) => left[0] - right[0])
|
|
140
|
+
.map(([status, mappings]) => [
|
|
141
|
+
String(status),
|
|
142
|
+
{
|
|
143
|
+
description: mappings.map((mapping) => mapping.title).join("; "),
|
|
144
|
+
content: { "application/problem+json": { schema: ref("Problem") } },
|
|
145
|
+
"x-problem-codes": mappings.map((mapping) => ({
|
|
146
|
+
code: mapping.code,
|
|
147
|
+
title: mapping.title,
|
|
148
|
+
from: [...mapping.from]
|
|
149
|
+
}))
|
|
150
|
+
}
|
|
151
|
+
]));
|
|
152
|
+
};
|
|
153
|
+
const operationObject = (endpoint) => {
|
|
154
|
+
const created = endpoint.method === "POST";
|
|
155
|
+
const tag = endpoint.path.split("/")[1] ?? "default";
|
|
156
|
+
return {
|
|
157
|
+
operationId: endpoint.operationId,
|
|
158
|
+
summary: endpoint.summary,
|
|
159
|
+
...withDefined([["description", endpoint.description]]),
|
|
160
|
+
tags: [tag],
|
|
161
|
+
...(endpoint.parameters.length === 0
|
|
162
|
+
? {}
|
|
163
|
+
: { parameters: endpoint.parameters.map(parameterObject) }),
|
|
164
|
+
...(endpoint.requestBody === undefined
|
|
165
|
+
? {}
|
|
166
|
+
: {
|
|
167
|
+
requestBody: {
|
|
168
|
+
required: endpoint.requestBody.required,
|
|
169
|
+
content: { "application/json": { schema: ref(endpoint.requestBody.model) } }
|
|
170
|
+
}
|
|
171
|
+
}),
|
|
172
|
+
responses: {
|
|
173
|
+
...Object.fromEntries(endpoint.responses.map((response) => [
|
|
174
|
+
String(response.status),
|
|
175
|
+
successObject(response, created)
|
|
176
|
+
])),
|
|
177
|
+
...errorResponses(endpoint.errors)
|
|
178
|
+
},
|
|
179
|
+
"x-soap-operations": [...endpoint.sources],
|
|
180
|
+
...(endpoint.evidence.length === 0 ? {} : { "x-evidence": [...endpoint.evidence] })
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
export const projectOpenApi = (design, options = {}) => {
|
|
184
|
+
const dialect = options.dialect ?? "3.1";
|
|
185
|
+
const paths = {};
|
|
186
|
+
for (const endpoint of design.endpoints) {
|
|
187
|
+
paths[endpoint.path] = {
|
|
188
|
+
...(paths[endpoint.path] ?? {}),
|
|
189
|
+
[endpoint.method.toLowerCase()]: operationObject(endpoint)
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const schemas = {};
|
|
193
|
+
for (const model of design.models)
|
|
194
|
+
schemas[model.name] = modelSchema(model, dialect);
|
|
195
|
+
for (const endpoint of design.endpoints) {
|
|
196
|
+
for (const response of endpoint.responses) {
|
|
197
|
+
if (response.list !== true || response.model === undefined)
|
|
198
|
+
continue;
|
|
199
|
+
schemas[listName(response)] = {
|
|
200
|
+
type: "object",
|
|
201
|
+
required: response.paged === true ? ["items", "page"] : ["items"],
|
|
202
|
+
properties: {
|
|
203
|
+
items: { type: "array", items: ref(response.model) },
|
|
204
|
+
...(response.paged === true ? { page: ref("PageInfo") } : {})
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
if (response.paged === true)
|
|
208
|
+
schemas["PageInfo"] = pageSchema;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
schemas["Problem"] = problemSchema;
|
|
212
|
+
return {
|
|
213
|
+
openapi: dialect === "3.0" ? "3.0.3" : "3.1.0",
|
|
214
|
+
info: {
|
|
215
|
+
title: design.title,
|
|
216
|
+
version: options.version ?? "1.0.0",
|
|
217
|
+
description: "Projected by soap-design from the approved REST design; do not edit. x-soap-* extensions trace every element to the SOAP service."
|
|
218
|
+
},
|
|
219
|
+
servers: [{ url: design.basePath }],
|
|
220
|
+
paths,
|
|
221
|
+
components: { schemas }
|
|
222
|
+
};
|
|
223
|
+
};
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Typed YAML
|
|
226
|
+
// A string that a YAML 1.1 or 1.2 reader would turn into something else
|
|
227
|
+
// (a number, boolean, null, date) or that is not plain-safe is quoted.
|
|
228
|
+
const ambiguous = new RegExp([
|
|
229
|
+
"^(true|false|yes|no|on|off|y|n|null|~|)$", // booleans and nulls, YAML 1.1 included
|
|
230
|
+
"^[-+]?(\\.\\d+|\\d[\\d_]*(\\.[\\d_]*)?)([eE][-+]?\\d+)?$", // numbers, 1_000 included
|
|
231
|
+
"^[-+]?\\.(inf|nan)$", // .inf, .nan
|
|
232
|
+
"^[-+]?\\d[\\d_]*(:[0-5]?\\d)+(\\.\\d*)?$", // YAML 1.1 sexagesimal (12:30)
|
|
233
|
+
"^0[xob]",
|
|
234
|
+
"^\\d{4}-\\d{2}-\\d{2}", // dates and timestamps
|
|
235
|
+
"^[-?:,[\\]{}#&*!|>'\"%@`\\s]",
|
|
236
|
+
"\\s$",
|
|
237
|
+
":$",
|
|
238
|
+
": ",
|
|
239
|
+
"\\s#",
|
|
240
|
+
"[\\n\\r\\t]"
|
|
241
|
+
].join("|"), "i");
|
|
242
|
+
const scalar = (value) => typeof value === "string"
|
|
243
|
+
? ambiguous.test(value)
|
|
244
|
+
? JSON.stringify(value)
|
|
245
|
+
: value
|
|
246
|
+
: value === null
|
|
247
|
+
? "null"
|
|
248
|
+
: String(value);
|
|
249
|
+
const key = (name) => /^[A-Za-z_$/][A-Za-z0-9_.$/{}-]*$/.test(name) && !ambiguous.test(name)
|
|
250
|
+
? name
|
|
251
|
+
: JSON.stringify(name);
|
|
252
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
253
|
+
/** YAML for JSON data, with types preserved for any conforming reader. */
|
|
254
|
+
export const renderTypedYaml = (value, indent = 0) => {
|
|
255
|
+
const pad = " ".repeat(indent);
|
|
256
|
+
if (Array.isArray(value)) {
|
|
257
|
+
if (value.length === 0)
|
|
258
|
+
return `${pad}[]\n`;
|
|
259
|
+
return value
|
|
260
|
+
.map((item) => {
|
|
261
|
+
if (!isObject(item) && !Array.isArray(item))
|
|
262
|
+
return `${pad}- ${scalar(item)}\n`;
|
|
263
|
+
if ((Array.isArray(item) && item.length === 0) ||
|
|
264
|
+
(isObject(item) && Object.keys(item).length === 0)) {
|
|
265
|
+
return `${pad}- ${Array.isArray(item) ? "[]" : "{}"}\n`;
|
|
266
|
+
}
|
|
267
|
+
return `${pad}- ${renderTypedYaml(item, indent + 2).slice(indent + 2)}`;
|
|
268
|
+
})
|
|
269
|
+
.join("");
|
|
270
|
+
}
|
|
271
|
+
if (isObject(value)) {
|
|
272
|
+
const entries = Object.entries(value);
|
|
273
|
+
if (entries.length === 0)
|
|
274
|
+
return `${pad}{}\n`;
|
|
275
|
+
return entries
|
|
276
|
+
.map(([name, item]) => {
|
|
277
|
+
if (!isObject(item) && !Array.isArray(item))
|
|
278
|
+
return `${pad}${key(name)}: ${scalar(item)}\n`;
|
|
279
|
+
if ((Array.isArray(item) && item.length === 0) ||
|
|
280
|
+
(isObject(item) && Object.keys(item).length === 0)) {
|
|
281
|
+
return `${pad}${key(name)}: ${Array.isArray(item) ? "[]" : "{}"}\n`;
|
|
282
|
+
}
|
|
283
|
+
return `${pad}${key(name)}:\n${renderTypedYaml(item, indent + 2)}`;
|
|
284
|
+
})
|
|
285
|
+
.join("");
|
|
286
|
+
}
|
|
287
|
+
return `${pad}${scalar(value)}\n`;
|
|
288
|
+
};
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Schema from "effect/Schema";
|
|
4
|
+
import { elementByName, operationByName } from "./Catalog.js";
|
|
5
|
+
import { readIfPresent, serviceDirectory, servicePaths } from "./Discover.js";
|
|
6
|
+
import { readEnvelope, SoapFault } from "./Envelope.js";
|
|
7
|
+
import { instanceFromXml, skeletonYaml } from "./Instance.js";
|
|
8
|
+
import { MaskingOverrides, MaskingReport } from "./Masking.js";
|
|
9
|
+
import { parseXml } from "./Xml.js";
|
|
10
|
+
import { isYamlMap, parseYaml, renderYaml } from "./Yaml.js";
|
|
11
|
+
// Request files and recorded exchanges under `.llm4ts/soap/<service>/`:
|
|
12
|
+
//
|
|
13
|
+
// samples/<operation>/<name>.request.yaml the user's request (primary form)
|
|
14
|
+
// samples/<operation>/<name>.request.xml or the raw body element / envelope
|
|
15
|
+
// samples/<operation>/<name>.exchange.json one masked request/response pair
|
|
16
|
+
// raw/<operation>/<name>.response.xml only with --keep-raw (gitignored)
|
|
17
|
+
// .masking-key the service's pseudonym key (gitignored)
|
|
18
|
+
//
|
|
19
|
+
// Exchanges are always masked and auth-stripped before they are written.
|
|
20
|
+
export const Provenance = Schema.Literals(["call", "soapui"]);
|
|
21
|
+
export class IssueRecord extends Schema.Class("IssueRecord")({
|
|
22
|
+
path: Schema.String,
|
|
23
|
+
detail: Schema.String
|
|
24
|
+
}) {
|
|
25
|
+
}
|
|
26
|
+
export class RecordedMessage extends Schema.Class("RecordedMessage")({
|
|
27
|
+
headers: Schema.Record(Schema.String, Schema.String),
|
|
28
|
+
/** The masked envelope, security headers removed. */
|
|
29
|
+
envelope: Schema.String
|
|
30
|
+
}) {
|
|
31
|
+
}
|
|
32
|
+
export class RecordedResponse extends Schema.Class("RecordedResponse")({
|
|
33
|
+
status: Schema.Int,
|
|
34
|
+
headers: Schema.Record(Schema.String, Schema.String),
|
|
35
|
+
envelope: Schema.String,
|
|
36
|
+
elapsedMs: Schema.Int
|
|
37
|
+
}) {
|
|
38
|
+
}
|
|
39
|
+
export class Exchange extends Schema.Class("Exchange")({
|
|
40
|
+
version: Schema.Literal(1),
|
|
41
|
+
operation: Schema.String,
|
|
42
|
+
name: Schema.String,
|
|
43
|
+
purpose: Schema.String,
|
|
44
|
+
provenance: Provenance,
|
|
45
|
+
recordedAt: Schema.String,
|
|
46
|
+
environment: Schema.optionalKey(Schema.String),
|
|
47
|
+
/** Endpoint without query string or credentials. */
|
|
48
|
+
endpoint: Schema.optionalKey(Schema.String),
|
|
49
|
+
request: Schema.optionalKey(RecordedMessage),
|
|
50
|
+
response: Schema.optionalKey(RecordedResponse),
|
|
51
|
+
fault: Schema.optionalKey(SoapFault),
|
|
52
|
+
requestIssues: Schema.Array(IssueRecord),
|
|
53
|
+
/** Where the response breaks its schema; findings, not errors. */
|
|
54
|
+
responseIssues: Schema.Array(IssueRecord),
|
|
55
|
+
masking: MaskingReport
|
|
56
|
+
}) {
|
|
57
|
+
}
|
|
58
|
+
export class SampleError extends Schema.TaggedError()("SampleError", {
|
|
59
|
+
path: Schema.String,
|
|
60
|
+
detail: Schema.String
|
|
61
|
+
}) {
|
|
62
|
+
get message() {
|
|
63
|
+
return `${this.path}: ${this.detail}`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export const isSampleName = (name) => /^[a-z0-9][a-z0-9-]{0,62}$/.test(name);
|
|
67
|
+
export const toSampleName = (raw) => raw
|
|
68
|
+
.normalize("NFKD")
|
|
69
|
+
.replace(/[^\w\s-]/g, "")
|
|
70
|
+
.trim()
|
|
71
|
+
.toLowerCase()
|
|
72
|
+
.replace(/[\s_]+/g, "-")
|
|
73
|
+
.replace(/-+/g, "-")
|
|
74
|
+
.replace(/^-|-$/g, "")
|
|
75
|
+
.slice(0, 63) || "sample";
|
|
76
|
+
export const samplePaths = (service, operation, name) => {
|
|
77
|
+
const directory = `${serviceDirectory(service)}/samples/${operation}`;
|
|
78
|
+
return {
|
|
79
|
+
directory,
|
|
80
|
+
yaml: `${directory}/${name}.request.yaml`,
|
|
81
|
+
xml: `${directory}/${name}.request.xml`,
|
|
82
|
+
exchange: `${directory}/${name}.exchange.json`,
|
|
83
|
+
raw: `${serviceDirectory(service)}/raw/${operation}/${name}.response.xml`
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Request files
|
|
88
|
+
export const renderRequestFile = (options) => {
|
|
89
|
+
const operation = operationByName(options.catalog, options.operation);
|
|
90
|
+
const input = operation === undefined ? undefined : elementByName(options.catalog, operation.input);
|
|
91
|
+
const header = [
|
|
92
|
+
`# ${options.operation}: ${options.purpose}`,
|
|
93
|
+
'# Edit the values, then: llm4ts run soap-sample "call <operation>/<name>"',
|
|
94
|
+
"# Optional fields are commented out; uncomment to send them. `~` sends xsi:nil.",
|
|
95
|
+
...(options.issues === undefined || options.issues.length === 0
|
|
96
|
+
? []
|
|
97
|
+
: [
|
|
98
|
+
"#",
|
|
99
|
+
"# To fix before calling:",
|
|
100
|
+
...options.issues.map((issue) => `# ${issue.path}: ${issue.detail}`)
|
|
101
|
+
])
|
|
102
|
+
];
|
|
103
|
+
// A body that is not a non-empty map or list is written inline (`body: {}`),
|
|
104
|
+
// never as an indented block the reader would not accept.
|
|
105
|
+
const inline = options.body !== undefined &&
|
|
106
|
+
!((isYamlMap(options.body) && Object.keys(options.body).length > 0) ||
|
|
107
|
+
(Array.isArray(options.body) && options.body.length > 0));
|
|
108
|
+
const body = options.body !== undefined
|
|
109
|
+
? inline
|
|
110
|
+
? []
|
|
111
|
+
: renderYaml(options.body, 2).trimEnd().split("\n")
|
|
112
|
+
: input === undefined
|
|
113
|
+
? []
|
|
114
|
+
: skeletonYaml(options.catalog, input, 2, options.seeds === undefined ? {} : { seeds: options.seeds });
|
|
115
|
+
const bodyKey = options.body !== undefined && inline ? `body: ${renderYaml(options.body).trim()}` : "body:";
|
|
116
|
+
return [
|
|
117
|
+
...header,
|
|
118
|
+
`operation: ${options.operation}`,
|
|
119
|
+
`purpose: ${JSON.stringify(options.purpose)}`,
|
|
120
|
+
bodyKey,
|
|
121
|
+
...body,
|
|
122
|
+
""
|
|
123
|
+
].join("\n");
|
|
124
|
+
};
|
|
125
|
+
export const readRequestFile = (workspace, catalog, service, operation, name) => Effect.gen(function* () {
|
|
126
|
+
const paths = samplePaths(service, operation, name);
|
|
127
|
+
const yaml = yield* readIfPresent(workspace, paths.yaml);
|
|
128
|
+
const xml = yield* readIfPresent(workspace, paths.xml);
|
|
129
|
+
if (yaml !== undefined && xml !== undefined) {
|
|
130
|
+
return yield* new SampleError({
|
|
131
|
+
path: paths.directory,
|
|
132
|
+
detail: `${name} has both a .yaml and an .xml request; keep one`
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
if (yaml !== undefined) {
|
|
136
|
+
const document = yield* parseYaml(yaml);
|
|
137
|
+
if (!isYamlMap(document) || document["body"] === undefined) {
|
|
138
|
+
return yield* new SampleError({
|
|
139
|
+
path: paths.yaml,
|
|
140
|
+
detail: "expected operation:, purpose:, and body:"
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (document["operation"] !== undefined && document["operation"] !== operation) {
|
|
144
|
+
return yield* new SampleError({
|
|
145
|
+
path: paths.yaml,
|
|
146
|
+
detail: `operation: says ${String(document["operation"])}, the file sits under ${operation}`
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
const purpose = typeof document["purpose"] === "string" ? document["purpose"] : name;
|
|
150
|
+
return { purpose, body: document["body"] ?? null, form: "yaml", readIssues: [] };
|
|
151
|
+
}
|
|
152
|
+
if (xml !== undefined) {
|
|
153
|
+
const declared = operationByName(catalog, operation);
|
|
154
|
+
const input = declared === undefined ? undefined : elementByName(catalog, declared.input);
|
|
155
|
+
if (input === undefined) {
|
|
156
|
+
return yield* new SampleError({
|
|
157
|
+
path: paths.xml,
|
|
158
|
+
detail: `${operation} is not in the catalog`
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const root = yield* parseXml(xml, { source: paths.xml });
|
|
162
|
+
const envelope = yield* readEnvelope(root).pipe(Effect.option);
|
|
163
|
+
const bodyElement = envelope._tag === "Some" ? envelope.value.payload : root;
|
|
164
|
+
if (bodyElement === undefined) {
|
|
165
|
+
return yield* new SampleError({ path: paths.xml, detail: "the envelope Body is empty" });
|
|
166
|
+
}
|
|
167
|
+
const read = instanceFromXml(catalog, input, bodyElement);
|
|
168
|
+
const purpose = /<!--\s*purpose:\s*([\s\S]*?)-->/.exec(xml)?.[1]?.trim() ?? name;
|
|
169
|
+
return { purpose, body: read.value, form: "xml", readIssues: read.issues };
|
|
170
|
+
}
|
|
171
|
+
return yield* new SampleError({
|
|
172
|
+
path: paths.directory,
|
|
173
|
+
detail: `no request ${name}; create one with: soap-sample "init ${operation} ${name}"`
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
/** Request names under an operation, from either form. */
|
|
177
|
+
export const listRequests = (workspace, service, operation) => Effect.map(workspace.discover(`${serviceDirectory(service)}/samples/${operation}/*.request.*`), (files) => [
|
|
178
|
+
...new Set(files.map((file) => (file.split("/").pop() ?? "").replace(/\.request\.(yaml|xml)$/, "")))
|
|
179
|
+
].sort());
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Exchanges
|
|
182
|
+
export const encodeExchange = (exchange) => `${JSON.stringify(Schema.encodeSync(Exchange)(exchange), null, 2)}\n`;
|
|
183
|
+
export const writeExchange = (workspace, service, exchange) => {
|
|
184
|
+
const path = samplePaths(service, exchange.operation, exchange.name).exchange;
|
|
185
|
+
return Effect.as(workspace.write(path, encodeExchange(exchange)), path);
|
|
186
|
+
};
|
|
187
|
+
export const readExchanges = (workspace, service, operation) => Effect.gen(function* () {
|
|
188
|
+
const pattern = `${serviceDirectory(service)}/samples/${operation ?? "*"}/*.exchange.json`;
|
|
189
|
+
const files = [...(yield* workspace.discover(pattern))].sort();
|
|
190
|
+
return yield* Effect.forEach(files, (file) => Effect.flatMap(workspace.read(file), (text) => Schema.decodeUnknownEffect(Schema.fromJsonString(Exchange))(text).pipe(Effect.mapError((error) => new SampleError({ path: file, detail: `not an exchange: ${error.message}` })))));
|
|
191
|
+
});
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Masking key and overrides
|
|
194
|
+
const keyPath = (service) => `${serviceDirectory(service)}/.masking-key`;
|
|
195
|
+
/**
|
|
196
|
+
* The service's pseudonym key: 32 random bytes, created on first use and
|
|
197
|
+
* kept (gitignored) so pseudonyms stay stable across runs.
|
|
198
|
+
*/
|
|
199
|
+
export const loadMaskingKey = (workspace, service, random = (size) => new Uint8Array(randomBytes(size))) => Effect.gen(function* () {
|
|
200
|
+
const existing = yield* readIfPresent(workspace, keyPath(service));
|
|
201
|
+
if (existing !== undefined) {
|
|
202
|
+
const hex = existing.trim();
|
|
203
|
+
if (!/^[0-9a-f]{64}$/.test(hex)) {
|
|
204
|
+
return yield* new SampleError({ path: keyPath(service), detail: "not a 64-digit hex key" });
|
|
205
|
+
}
|
|
206
|
+
return Uint8Array.from(Buffer.from(hex, "hex"));
|
|
207
|
+
}
|
|
208
|
+
const key = random(32);
|
|
209
|
+
yield* workspace.write(keyPath(service), `${Buffer.from(key).toString("hex")}\n`);
|
|
210
|
+
yield* workspace.write(servicePaths(service).gitignore, ["auth.json", "raw/", ".masking-key", ""].join("\n"));
|
|
211
|
+
return key;
|
|
212
|
+
});
|
|
213
|
+
export const loadMaskingOverrides = (workspace, service) => {
|
|
214
|
+
const path = servicePaths(service).masking;
|
|
215
|
+
return Effect.flatMap(readIfPresent(workspace, path), (text) => text === undefined
|
|
216
|
+
? Effect.succeed(undefined)
|
|
217
|
+
: Schema.decodeUnknownEffect(Schema.fromJsonString(MaskingOverrides))(text).pipe(Effect.mapError(() => new SampleError({
|
|
218
|
+
path,
|
|
219
|
+
detail: 'expected { "fields": { "<element>": "mask" | "keep" } }'
|
|
220
|
+
}))));
|
|
221
|
+
};
|
|
222
|
+
/** First value seen per field name across request bodies; SoapUI `${...}` expansions skipped. */
|
|
223
|
+
export const collectSeeds = (bodies) => {
|
|
224
|
+
const seeds = new Map();
|
|
225
|
+
const collect = (value) => {
|
|
226
|
+
if (value === null || typeof value === "string")
|
|
227
|
+
return;
|
|
228
|
+
if (Array.isArray(value)) {
|
|
229
|
+
for (const item of value)
|
|
230
|
+
collect(item);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (!isYamlMap(value))
|
|
234
|
+
return;
|
|
235
|
+
for (const [key, item] of Object.entries(value)) {
|
|
236
|
+
if (typeof item === "string") {
|
|
237
|
+
if (!seeds.has(key) && !item.includes("${") && item !== "?")
|
|
238
|
+
seeds.set(key, item);
|
|
239
|
+
}
|
|
240
|
+
else
|
|
241
|
+
collect(item);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
for (const body of bodies)
|
|
245
|
+
collect(body);
|
|
246
|
+
return seeds;
|
|
247
|
+
};
|
|
248
|
+
/** Request bodies of recorded exchanges (already masked). */
|
|
249
|
+
export const bodiesFromExchanges = (exchanges, catalog) => Effect.gen(function* () {
|
|
250
|
+
const bodies = [];
|
|
251
|
+
for (const exchange of exchanges) {
|
|
252
|
+
if (exchange.request === undefined)
|
|
253
|
+
continue;
|
|
254
|
+
const operation = operationByName(catalog, exchange.operation);
|
|
255
|
+
const input = operation === undefined ? undefined : elementByName(catalog, operation.input);
|
|
256
|
+
if (input === undefined)
|
|
257
|
+
continue;
|
|
258
|
+
const parsed = yield* parseXml(exchange.request.envelope).pipe(Effect.option);
|
|
259
|
+
if (parsed._tag === "None")
|
|
260
|
+
continue;
|
|
261
|
+
const envelope = yield* readEnvelope(parsed.value).pipe(Effect.option);
|
|
262
|
+
const payload = envelope._tag === "Some" ? envelope.value.payload : undefined;
|
|
263
|
+
if (payload !== undefined)
|
|
264
|
+
bodies.push(instanceFromXml(catalog, input, payload).value);
|
|
265
|
+
}
|
|
266
|
+
return bodies;
|
|
267
|
+
});
|
|
268
|
+
/** Values seen in earlier requests, by field name, to seed new skeletons. */
|
|
269
|
+
export const seedsFromExchanges = (exchanges, catalog) => Effect.map(bodiesFromExchanges(exchanges, catalog), collectSeeds);
|
|
270
|
+
/**
|
|
271
|
+
* Seeds from everything on disk for the service: recorded exchanges first
|
|
272
|
+
* (values the service answered to), then request files (imported ones are
|
|
273
|
+
* masked). Unreadable request files are skipped.
|
|
274
|
+
*/
|
|
275
|
+
export const seedsForService = (workspace, catalog, service) => Effect.gen(function* () {
|
|
276
|
+
const fromExchanges = yield* bodiesFromExchanges(yield* readExchanges(workspace, service), catalog);
|
|
277
|
+
const fromFiles = [];
|
|
278
|
+
for (const operation of catalog.operations) {
|
|
279
|
+
for (const name of yield* listRequests(workspace, service, operation.name)) {
|
|
280
|
+
const request = yield* readRequestFile(workspace, catalog, service, operation.name, name).pipe(Effect.option);
|
|
281
|
+
if (request._tag === "Some")
|
|
282
|
+
fromFiles.push(request.value.body);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return collectSeeds([...fromExchanges, ...fromFiles]);
|
|
286
|
+
});
|
|
287
|
+
export const issueRecords = (issues) => issues.map((issue) => new IssueRecord(issue));
|