@llm4ts/shell 2.12.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.
Files changed (61) hide show
  1. package/flows/epic-stories.js +9 -3
  2. package/flows/lib/epic-stories.js +24 -4
  3. package/kits/soap-ace/README.md +313 -0
  4. package/kits/soap-ace/fixtures/demo-bank-soap/RUNBOOK.md +102 -0
  5. package/kits/soap-ace/fixtures/demo-bank-soap/design/api-design.md +620 -0
  6. package/kits/soap-ace/fixtures/demo-bank-soap/responses/cercaConti.xml +3 -0
  7. package/kits/soap-ace/fixtures/demo-bank-soap/responses/cercaMovimenti.xml +3 -0
  8. package/kits/soap-ace/fixtures/demo-bank-soap/responses/confermaBonifico.xml +3 -0
  9. package/kits/soap-ace/fixtures/demo-bank-soap/responses/dettaglioConto.xml +3 -0
  10. package/kits/soap-ace/fixtures/demo-bank-soap/responses/inserisciBonifico.xml +3 -0
  11. package/kits/soap-ace/fixtures/demo-bank-soap/responses/revocaBonifico.xml +3 -0
  12. package/kits/soap-ace/fixtures/demo-bank-soap/soapui/DemoBank-soapui-project.xml +39 -0
  13. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/DemoBank.wsdl +186 -0
  14. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/LegacyRpc.wsdl +29 -0
  15. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/banking.xsd +10 -0
  16. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/bonifici.xsd +107 -0
  17. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/common.xsd +93 -0
  18. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/conti.xsd +71 -0
  19. package/kits/soap-ace/fixtures/demo-bank-soap/wsdl/xsd/movimenti.xsd +55 -0
  20. package/kits/soap-ace/flows/lib/soap/Analysis.js +508 -0
  21. package/kits/soap-ace/flows/lib/soap/Auth.js +179 -0
  22. package/kits/soap-ace/flows/lib/soap/Call.js +186 -0
  23. package/kits/soap-ace/flows/lib/soap/CallPolicy.js +50 -0
  24. package/kits/soap-ace/flows/lib/soap/Catalog.js +157 -0
  25. package/kits/soap-ace/flows/lib/soap/Classification.js +254 -0
  26. package/kits/soap-ace/flows/lib/soap/Design.js +497 -0
  27. package/kits/soap-ace/flows/lib/soap/DesignProposal.js +204 -0
  28. package/kits/soap-ace/flows/lib/soap/Discover.js +139 -0
  29. package/kits/soap-ace/flows/lib/soap/Envelope.js +132 -0
  30. package/kits/soap-ace/flows/lib/soap/Epic.js +239 -0
  31. package/kits/soap-ace/flows/lib/soap/Instance.js +675 -0
  32. package/kits/soap-ace/flows/lib/soap/Mapping.js +130 -0
  33. package/kits/soap-ace/flows/lib/soap/Masking.js +508 -0
  34. package/kits/soap-ace/flows/lib/soap/OpenApi.js +288 -0
  35. package/kits/soap-ace/flows/lib/soap/Samples.js +287 -0
  36. package/kits/soap-ace/flows/lib/soap/Scenarios.js +132 -0
  37. package/kits/soap-ace/flows/lib/soap/SoapUiImport.js +200 -0
  38. package/kits/soap-ace/flows/lib/soap/Stub.js +34 -0
  39. package/kits/soap-ace/flows/lib/soap/Transport.js +167 -0
  40. package/kits/soap-ace/flows/lib/soap/Wsdl.js +533 -0
  41. package/kits/soap-ace/flows/lib/soap/Xml.js +453 -0
  42. package/kits/soap-ace/flows/lib/soap/Xsd.js +471 -0
  43. package/kits/soap-ace/flows/lib/soap/Yaml.js +309 -0
  44. package/kits/soap-ace/flows/soap-design.js +167 -0
  45. package/kits/soap-ace/flows/soap-discover.js +85 -0
  46. package/kits/soap-ace/flows/soap-epic.js +204 -0
  47. package/kits/soap-ace/flows/soap-sample.js +265 -0
  48. package/kits/soap-ace/packs/ace12-rest/api-style.md +63 -0
  49. package/kits/soap-ace/packs/ace12-rest/pack.md +18 -0
  50. package/kits/soap-ace/packs/ace12-rest/prompts/implement.md +8 -0
  51. package/kits/soap-ace/packs/ace12-rest/reviewers/contract.md +5 -0
  52. package/kits/soap-ace/patterns/PAT-ACE-001-esito-to-problem.md +8 -0
  53. package/kits/soap-ace/patterns/PAT-ACE-002-fault-to-problem.md +8 -0
  54. package/kits/soap-ace/patterns/PAT-ACE-003-amounts-and-enums.md +8 -0
  55. package/kits/soap-ace/patterns/PAT-ACE-004-pagination.md +7 -0
  56. package/kits/soap-ace/patterns/PAT-ACE-005-nil-and-absence.md +7 -0
  57. package/kits/soap-ace/scaffolds/ace12-rest-api/CONTRIBUTING.md +56 -0
  58. package/kits/soap-ace/scaffolds/ace12-rest-api/README.md +16 -0
  59. package/kits/soap-ace/scaffolds/ace12-rest-api/scripts/ace-gates-container.sh +8 -0
  60. package/kits/soap-ace/scaffolds/ace12-rest-api/scripts/ace-gates.sh +41 -0
  61. 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));