@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,204 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
import { ComplexTypeDef, localName } from "./Catalog.js";
|
|
3
|
+
import { effectiveClass } from "./Classification.js";
|
|
4
|
+
import { ApiDesign } from "./Design.js";
|
|
5
|
+
// The reasoning seat drafts the REST design; it never has the last word.
|
|
6
|
+
// The prompt carries the style guide as hard rules, the confirmed classes,
|
|
7
|
+
// the 1:1 mapping (the only paths a source may cite), the named XSD types a
|
|
8
|
+
// model may bind to, and the analysis evidence. The reply is decoded into
|
|
9
|
+
// `ApiDesign` and then goes through `checkDesign` like a hand-written one;
|
|
10
|
+
// `revise` feeds the check's findings back for another draft.
|
|
11
|
+
const property = { type: "object", required: ["name", "type", "required"] };
|
|
12
|
+
const designJsonSchema = {
|
|
13
|
+
type: "object",
|
|
14
|
+
required: ["version", "title", "basePath", "endpoints", "models", "excluded"],
|
|
15
|
+
properties: {
|
|
16
|
+
version: { const: 1 },
|
|
17
|
+
title: { type: "string" },
|
|
18
|
+
basePath: { type: "string", description: "/v1" },
|
|
19
|
+
endpoints: {
|
|
20
|
+
type: "array",
|
|
21
|
+
items: {
|
|
22
|
+
type: "object",
|
|
23
|
+
required: [
|
|
24
|
+
"method",
|
|
25
|
+
"path",
|
|
26
|
+
"operationId",
|
|
27
|
+
"summary",
|
|
28
|
+
"sources",
|
|
29
|
+
"parameters",
|
|
30
|
+
"responses",
|
|
31
|
+
"errors",
|
|
32
|
+
"evidence"
|
|
33
|
+
],
|
|
34
|
+
properties: {
|
|
35
|
+
method: { enum: ["GET", "POST", "PUT", "PATCH", "DELETE"] },
|
|
36
|
+
path: { type: "string" },
|
|
37
|
+
operationId: { type: "string" },
|
|
38
|
+
summary: { type: "string" },
|
|
39
|
+
sources: { type: "array", items: { type: "string" } },
|
|
40
|
+
parameters: { type: "array", items: property },
|
|
41
|
+
requestBody: { type: "object", required: ["model", "required"] },
|
|
42
|
+
responses: {
|
|
43
|
+
type: "array",
|
|
44
|
+
items: { type: "object", required: ["status", "description"] }
|
|
45
|
+
},
|
|
46
|
+
errors: {
|
|
47
|
+
type: "array",
|
|
48
|
+
items: { type: "object", required: ["status", "code", "title", "from"] }
|
|
49
|
+
},
|
|
50
|
+
evidence: { type: "array", items: { type: "string" } }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
models: {
|
|
55
|
+
type: "array",
|
|
56
|
+
items: {
|
|
57
|
+
type: "object",
|
|
58
|
+
required: ["name", "properties"],
|
|
59
|
+
properties: { properties: { type: "array", items: property } }
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
excluded: { type: "array", items: { type: "object", required: ["operation", "reason"] } },
|
|
63
|
+
notes: { type: "array", items: { type: "string" } }
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const fieldLine = (field) => ` ${field.xsd}: ${field.jsonType}${field.format === undefined ? "" : `(${field.format})`} ${field.xsdType}${field.required ? "" : " optional"}${field.enumeration === undefined ? "" : ` enum ${field.enumeration.join("|")}`}`;
|
|
67
|
+
const analysisLines = (analysis) => [
|
|
68
|
+
` ${analysis.operation}: ${analysis.exchanges} samples`,
|
|
69
|
+
...analysis.observations.map((line) => ` - ${line}`),
|
|
70
|
+
...analysis.outcomes.map((outcome) => ` - outcome ${outcome.code}${outcome.description === undefined ? "" : ` "${outcome.description}"`} ×${outcome.count}`),
|
|
71
|
+
...analysis.faults.map((fault) => ` - fault ${fault.detailElement ?? fault.code}: ${fault.reason} ×${fault.count}`),
|
|
72
|
+
...(analysis.pagination === undefined
|
|
73
|
+
? []
|
|
74
|
+
: [
|
|
75
|
+
` - pagination ${analysis.pagination.style}: request ${analysis.pagination.requestFields.join(", ")}; response ${analysis.pagination.responseFields.join(", ")}; ${analysis.pagination.observed}`
|
|
76
|
+
]),
|
|
77
|
+
...analysis.fields
|
|
78
|
+
.filter((field) => field.distinct > 0 &&
|
|
79
|
+
field.distinct <= 8 &&
|
|
80
|
+
field.values.every((entry) => entry.value.length <= 20))
|
|
81
|
+
.map((field) => ` - values of ${field.path}: ${field.values.map((entry) => entry.value).join(", ")}`)
|
|
82
|
+
];
|
|
83
|
+
const exampleDesign = {
|
|
84
|
+
version: 1,
|
|
85
|
+
title: "Example",
|
|
86
|
+
basePath: "/v1",
|
|
87
|
+
endpoints: [
|
|
88
|
+
{
|
|
89
|
+
method: "GET",
|
|
90
|
+
path: "/orders/{orderId}",
|
|
91
|
+
operationId: "getOrder",
|
|
92
|
+
summary: "One order",
|
|
93
|
+
sources: ["leggiOrdine"],
|
|
94
|
+
parameters: [
|
|
95
|
+
{ name: "orderId", in: "path", required: true, type: "string", source: "idOrdine" }
|
|
96
|
+
],
|
|
97
|
+
responses: [{ status: 200, description: "The order", model: "Order", source: "" }],
|
|
98
|
+
errors: [{ status: 404, code: "order-not-found", title: "No such order", from: ["KO04"] }],
|
|
99
|
+
evidence: ["analysis/leggiOrdine: KO04 inside a 200 when the order does not exist"]
|
|
100
|
+
}
|
|
101
|
+
],
|
|
102
|
+
models: [
|
|
103
|
+
{
|
|
104
|
+
name: "Order",
|
|
105
|
+
sourceType: "leggiOrdineResponse",
|
|
106
|
+
properties: [
|
|
107
|
+
{ name: "id", type: "string", required: true, source: "ordine.id" },
|
|
108
|
+
{
|
|
109
|
+
name: "status",
|
|
110
|
+
type: "string",
|
|
111
|
+
required: true,
|
|
112
|
+
source: "ordine.stato",
|
|
113
|
+
enum: ["OPEN", "SHIPPED"],
|
|
114
|
+
enumMap: { APERTO: "OPEN", SPEDITO: "SHIPPED" }
|
|
115
|
+
},
|
|
116
|
+
{ name: "total", type: "object", ref: "Amount", required: true, source: "ordine.totale" }
|
|
117
|
+
]
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: "Amount",
|
|
121
|
+
sourceType: "ImportoType",
|
|
122
|
+
properties: [
|
|
123
|
+
{ name: "amount", type: "string", format: "decimal", required: true, source: "valore" },
|
|
124
|
+
{ name: "currency", type: "string", required: true, source: "divisa" }
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
],
|
|
128
|
+
excluded: []
|
|
129
|
+
};
|
|
130
|
+
export const designPrompt = (options) => {
|
|
131
|
+
const { catalog } = options;
|
|
132
|
+
const namedTypes = catalog.types
|
|
133
|
+
.filter((type) => !type.anonymous && type instanceof ComplexTypeDef)
|
|
134
|
+
.map((type) => localName(type.name));
|
|
135
|
+
return [
|
|
136
|
+
"You design the REST API that an IBM ACE integration will expose in front of a bank's SOAP service.",
|
|
137
|
+
"Design resources, not a SOAP facade: group operations into resources, map SOAP faults and business",
|
|
138
|
+
"outcome codes to HTTP statuses and problem codes, flatten SOAP wrappers, drop the esito envelope, and",
|
|
139
|
+
"translate Italian field names to English camelCase. Every design element must be traceable to the SOAP",
|
|
140
|
+
"service, and every decision the samples inform must cite them in `evidence`.",
|
|
141
|
+
"",
|
|
142
|
+
"## Style guide (hard rules)",
|
|
143
|
+
"",
|
|
144
|
+
options.style.trim(),
|
|
145
|
+
"",
|
|
146
|
+
"## Operations (confirmed class: read ones may be GET; mutating ones never are)",
|
|
147
|
+
"",
|
|
148
|
+
...catalog.operations.map((operation) => `- ${operation.name} [${effectiveClass(options.operations, operation.name)}]${operation.documentation === undefined ? "" : `: ${operation.documentation}`}`),
|
|
149
|
+
"",
|
|
150
|
+
"## 1:1 mapping: the only XSD paths a source may cite",
|
|
151
|
+
"",
|
|
152
|
+
...options.mapping.operations.flatMap((operation) => [
|
|
153
|
+
`${operation.operation}`,
|
|
154
|
+
` request ${operation.request}:`,
|
|
155
|
+
...operation.requestFields.map(fieldLine),
|
|
156
|
+
...(operation.response === undefined
|
|
157
|
+
? []
|
|
158
|
+
: [` response ${operation.response}:`, ...operation.responseFields.map(fieldLine)])
|
|
159
|
+
]),
|
|
160
|
+
"",
|
|
161
|
+
`Named XSD types a model may bind to (besides the request/response elements above): ${namedTypes.join(", ")}`,
|
|
162
|
+
"",
|
|
163
|
+
"## Evidence from masked samples",
|
|
164
|
+
"",
|
|
165
|
+
...(options.analyses.length === 0
|
|
166
|
+
? ["(no samples analysed)"]
|
|
167
|
+
: options.analyses.flatMap(analysisLines)),
|
|
168
|
+
"",
|
|
169
|
+
"## Format",
|
|
170
|
+
"",
|
|
171
|
+
"- Each model binds to one XSD element or named type (`sourceType`); each property's `source` is an XSD",
|
|
172
|
+
" path relative to it (`saldo.valore`), or `derivation` explains how it is computed.",
|
|
173
|
+
"- Each parameter's `source` is a path in the request of the endpoint's first SOAP operation.",
|
|
174
|
+
'- Each response names its `model` and the `source` path in the SOAP response it comes from (`""` for the',
|
|
175
|
+
" whole response); collections set `list: true` (and `paged: true` when the service pages).",
|
|
176
|
+
"- An `enumMap` must cover every declared SOAP value and every value the samples showed.",
|
|
177
|
+
"- `errors[].from` lists the outcome codes (KO17) and fault detail elements (ServizioFault) it maps; map",
|
|
178
|
+
" every one the evidence shows.",
|
|
179
|
+
"- Every SOAP operation appears in some endpoint's `sources` or in `excluded` with a reason.",
|
|
180
|
+
"",
|
|
181
|
+
"Example of the shape (not of this service):",
|
|
182
|
+
"```json",
|
|
183
|
+
JSON.stringify(exampleDesign, null, 2),
|
|
184
|
+
"```",
|
|
185
|
+
"",
|
|
186
|
+
"Reply with the ApiDesign JSON only."
|
|
187
|
+
].join("\n");
|
|
188
|
+
};
|
|
189
|
+
export const proposeDesign = (reasoning, options) => reasoning.executeStructured(designPrompt(options), ApiDesign, designJsonSchema);
|
|
190
|
+
export const reviseDesign = (reasoning, options) => reasoning.executeStructured([
|
|
191
|
+
designPrompt(options),
|
|
192
|
+
"",
|
|
193
|
+
"## Current draft",
|
|
194
|
+
"",
|
|
195
|
+
"```json",
|
|
196
|
+
JSON.stringify(Schema.encodeSync(ApiDesign)(options.current), null, 2),
|
|
197
|
+
"```",
|
|
198
|
+
"",
|
|
199
|
+
"## The design check found",
|
|
200
|
+
"",
|
|
201
|
+
...options.issues.map((issue) => `- ${issue.severity} ${issue.where}: ${issue.detail}`),
|
|
202
|
+
"",
|
|
203
|
+
"Fix every error and every warning you can; keep everything else as it is. Reply with the full ApiDesign JSON."
|
|
204
|
+
].join("\n"), ApiDesign, designJsonSchema);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Schema from "effect/Schema";
|
|
4
|
+
import { ComplexTypeDef, elementByName, localName, splitClark, typeByName, WsdlCatalog } from "./Catalog.js";
|
|
5
|
+
import { decodeAuthProfile } from "./Auth.js";
|
|
6
|
+
import { classifyOperations, operationsDrift, parseOperationsFile, renderOperationsFile } from "./Classification.js";
|
|
7
|
+
import { readCatalog } from "./Wsdl.js";
|
|
8
|
+
// `soap-discover` as a library call: read the catalog, persist it with a
|
|
9
|
+
// human summary, and propose operation classes unless a classification file
|
|
10
|
+
// already exists (the existing file wins, as every approval file does).
|
|
11
|
+
export class CatalogFileError extends Schema.TaggedError()("CatalogFileError", {
|
|
12
|
+
path: Schema.String,
|
|
13
|
+
detail: Schema.String
|
|
14
|
+
}) {
|
|
15
|
+
get message() {
|
|
16
|
+
return `${this.path}: ${this.detail}`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** `.llm4ts/soap/<service>` */
|
|
20
|
+
export const serviceDirectory = (service) => `.llm4ts/soap/${service}`;
|
|
21
|
+
export const servicePaths = (service) => {
|
|
22
|
+
const directory = serviceDirectory(service);
|
|
23
|
+
return {
|
|
24
|
+
directory,
|
|
25
|
+
catalog: `${directory}/catalog.json`,
|
|
26
|
+
summary: `${directory}/catalog.md`,
|
|
27
|
+
operations: `${directory}/operations.md`,
|
|
28
|
+
auth: `${directory}/auth.json`,
|
|
29
|
+
masking: `${directory}/masking.json`,
|
|
30
|
+
gitignore: `${directory}/.gitignore`
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
/** A directory-safe service name: explicit, else the WSDL's service, else the file name. */
|
|
34
|
+
export const serviceName = (catalog, location, explicit) => {
|
|
35
|
+
const raw = explicit ??
|
|
36
|
+
catalog.endpoints[0]?.service ??
|
|
37
|
+
basename(location.replace(/[?#].*$/, "")).replace(/\.[^.]*$/, "");
|
|
38
|
+
const safe = raw.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
39
|
+
return safe === "" ? "service" : safe;
|
|
40
|
+
};
|
|
41
|
+
/** What never leaves the machine: credentials references, raw captures, the masking key. */
|
|
42
|
+
export const serviceGitignore = ["auth.json", "raw/", ".masking-key", ""].join("\n");
|
|
43
|
+
export const loadAuthProfile = (workspace, service) => Effect.flatMap(readIfPresent(workspace, servicePaths(service).auth), (text) => text === undefined ? Effect.succeed(undefined) : decodeAuthProfile(text));
|
|
44
|
+
export const readIfPresent = (workspace, path) => Effect.flatMap(workspace.discover(path), (found) => found.includes(path) ? workspace.read(path) : Effect.succeed(undefined));
|
|
45
|
+
export const encodeCatalog = (catalog) => `${JSON.stringify(Schema.encodeSync(WsdlCatalog)(catalog), null, 2)}\n`;
|
|
46
|
+
export const loadCatalog = (workspace, service) => {
|
|
47
|
+
const path = servicePaths(service).catalog;
|
|
48
|
+
return Effect.flatMap(workspace.read(path), (text) => Schema.decodeUnknownEffect(Schema.fromJsonString(WsdlCatalog))(text).pipe(Effect.mapError((error) => new CatalogFileError({ path, detail: `not a catalog: ${error.message}` }))));
|
|
49
|
+
};
|
|
50
|
+
export const loadOperationsFile = (workspace, service) => Effect.flatMap(readIfPresent(workspace, servicePaths(service).operations), (text) => text === undefined ? Effect.succeed(undefined) : parseOperationsFile(text));
|
|
51
|
+
const occurs = (min, max) => max === "unbounded" ? `${min}..*` : min === max ? `${min}` : `${min}..${max}`;
|
|
52
|
+
const fieldLines = (catalog, element) => {
|
|
53
|
+
if (element === undefined)
|
|
54
|
+
return [" (none)"];
|
|
55
|
+
const declared = elementByName(catalog, element);
|
|
56
|
+
const type = typeByName(catalog, declared?.type ?? "");
|
|
57
|
+
if (!(type instanceof ComplexTypeDef)) {
|
|
58
|
+
return [` - value: ${localName(declared?.type ?? element)}`];
|
|
59
|
+
}
|
|
60
|
+
return type.fields.map((field) => ` - ${field.name}: ${localName(field.type)} [${occurs(field.minOccurs, field.maxOccurs)}]${field.nillable ? " nillable" : ""}`);
|
|
61
|
+
};
|
|
62
|
+
const operationSection = (catalog, operation) => [
|
|
63
|
+
`### ${operation.name}`,
|
|
64
|
+
"",
|
|
65
|
+
...(operation.documentation === undefined ? [] : [operation.documentation, ""]),
|
|
66
|
+
`- SOAPAction: \`${operation.soapAction}\``,
|
|
67
|
+
`- SOAP ${operation.soapVersion}, binding ${operation.binding}, ${operation.wrapped ? "wrapped" : "bare"}`,
|
|
68
|
+
`- request \`${localName(operation.input)}\`:`,
|
|
69
|
+
...fieldLines(catalog, operation.input),
|
|
70
|
+
`- response \`${operation.output === undefined ? "(one-way)" : localName(operation.output)}\`:`,
|
|
71
|
+
...fieldLines(catalog, operation.output),
|
|
72
|
+
`- faults: ${operation.faults.length === 0 ? "none" : operation.faults.map((fault) => `${fault.name} (${localName(fault.element)})`).join(", ")}`,
|
|
73
|
+
""
|
|
74
|
+
];
|
|
75
|
+
export const renderCatalogSummary = (service, catalog) => {
|
|
76
|
+
const namedTypes = catalog.types.filter((type) => !type.anonymous);
|
|
77
|
+
const namespaces = [...new Set(catalog.types.map((type) => splitClark(type.name).namespace))];
|
|
78
|
+
return [
|
|
79
|
+
`# SOAP catalog: ${service}`,
|
|
80
|
+
"",
|
|
81
|
+
`WSDL ${catalog.wsdlVersion}, target namespace \`${catalog.targetNamespace}\`. Generated by`,
|
|
82
|
+
"`soap-discover` from the documents below; regenerate rather than edit.",
|
|
83
|
+
"",
|
|
84
|
+
"## Summary",
|
|
85
|
+
"",
|
|
86
|
+
`- ${catalog.operations.length} operations, ${catalog.endpoints.length} endpoints`,
|
|
87
|
+
`- ${namedTypes.length} named types, ${catalog.elements.length} global elements across ${namespaces.length} namespaces`,
|
|
88
|
+
`- ${catalog.openQuestions.length} open questions`,
|
|
89
|
+
"",
|
|
90
|
+
"## Documents",
|
|
91
|
+
"",
|
|
92
|
+
...catalog.documents.map((document) => `- ${document}`),
|
|
93
|
+
"",
|
|
94
|
+
"## Endpoints",
|
|
95
|
+
"",
|
|
96
|
+
...(catalog.endpoints.length === 0
|
|
97
|
+
? ["(none declared)"]
|
|
98
|
+
: catalog.endpoints.map((endpoint) => `- ${endpoint.service}/${endpoint.port} — SOAP ${endpoint.soapVersion} — ${endpoint.address}`)),
|
|
99
|
+
"",
|
|
100
|
+
"## Open questions",
|
|
101
|
+
"",
|
|
102
|
+
...(catalog.openQuestions.length === 0
|
|
103
|
+
? ["None: every construct in the WSDL and its schemas is modelled."]
|
|
104
|
+
: catalog.openQuestions.map((question) => `- **${question.code}** ${question.subject} (${question.location}): ${question.detail}`)),
|
|
105
|
+
"",
|
|
106
|
+
"## Operations",
|
|
107
|
+
"",
|
|
108
|
+
...catalog.operations.flatMap((operation) => operationSection(catalog, operation))
|
|
109
|
+
].join("\n");
|
|
110
|
+
};
|
|
111
|
+
export const discoverService = (options) => Effect.gen(function* () {
|
|
112
|
+
const catalog = yield* readCatalog(options.location);
|
|
113
|
+
const service = serviceName(catalog, options.location, options.service);
|
|
114
|
+
const paths = servicePaths(service);
|
|
115
|
+
yield* options.workspace.write(paths.gitignore, serviceGitignore);
|
|
116
|
+
yield* options.workspace.write(paths.catalog, encodeCatalog(catalog));
|
|
117
|
+
yield* options.workspace.write(paths.summary, renderCatalogSummary(service, catalog));
|
|
118
|
+
const existing = yield* loadOperationsFile(options.workspace, service);
|
|
119
|
+
if (existing !== undefined) {
|
|
120
|
+
return {
|
|
121
|
+
service,
|
|
122
|
+
paths,
|
|
123
|
+
catalog,
|
|
124
|
+
proposals: undefined,
|
|
125
|
+
existing,
|
|
126
|
+
drift: operationsDrift(existing, catalog)
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const proposals = yield* classifyOperations(catalog, options.judgment);
|
|
130
|
+
yield* options.workspace.write(paths.operations, renderOperationsFile(service, catalog, proposals));
|
|
131
|
+
return {
|
|
132
|
+
service,
|
|
133
|
+
paths,
|
|
134
|
+
catalog,
|
|
135
|
+
proposals,
|
|
136
|
+
existing: undefined,
|
|
137
|
+
drift: { missing: [], unknown: [] }
|
|
138
|
+
};
|
|
139
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Redacted from "effect/Redacted";
|
|
4
|
+
import * as Schema from "effect/Schema";
|
|
5
|
+
import { elements, escapeAttribute, escapeText, firstChild, is, textOf } from "./Xml.js";
|
|
6
|
+
// SOAP envelopes: building a request around a body, the WS-Security
|
|
7
|
+
// UsernameToken header, reading a response (body or fault), and removing
|
|
8
|
+
// every security header before an exchange is persisted.
|
|
9
|
+
export const Soap11Namespace = "http://schemas.xmlsoap.org/soap/envelope/";
|
|
10
|
+
export const Soap12Namespace = "http://www.w3.org/2003/05/soap-envelope";
|
|
11
|
+
export const WsseNamespace = "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd";
|
|
12
|
+
export const WsuNamespace = "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd";
|
|
13
|
+
const TokenProfile = "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0";
|
|
14
|
+
const Base64Binary = "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary";
|
|
15
|
+
export const envelopeNamespace = (version) => version === "1.1" ? Soap11Namespace : Soap12Namespace;
|
|
16
|
+
/** HTTP headers a SOAP request carries for its version. */
|
|
17
|
+
export const soapHeaders = (version, soapAction) => version === "1.1"
|
|
18
|
+
? { "content-type": "text/xml; charset=utf-8", soapaction: `"${soapAction}"` }
|
|
19
|
+
: {
|
|
20
|
+
"content-type": `application/soap+xml; charset=utf-8${soapAction === "" ? "" : `; action="${escapeAttribute(soapAction)}"`}`
|
|
21
|
+
};
|
|
22
|
+
/** `Base64(SHA-1(nonce + created + password))`, per the UsernameToken profile. */
|
|
23
|
+
export const passwordDigest = (material, password) => createHash("sha1")
|
|
24
|
+
.update(Buffer.from(material.nonce))
|
|
25
|
+
.update(material.created, "utf8")
|
|
26
|
+
.update(password, "utf8")
|
|
27
|
+
.digest("base64");
|
|
28
|
+
export const usernameTokenHeader = (token, material) => {
|
|
29
|
+
const password = Redacted.value(token.password);
|
|
30
|
+
const passwordValue = token.passwordType === "digest" ? passwordDigest(material, password) : password;
|
|
31
|
+
const type = token.passwordType === "digest" ? "PasswordDigest" : "PasswordText";
|
|
32
|
+
return Redacted.make([
|
|
33
|
+
`<wsse:Security xmlns:wsse="${WsseNamespace}" xmlns:wsu="${WsuNamespace}" soapenv:mustUnderstand="1">`,
|
|
34
|
+
'<wsse:UsernameToken wsu:Id="UsernameToken-1">',
|
|
35
|
+
`<wsse:Username>${escapeText(token.user)}</wsse:Username>`,
|
|
36
|
+
`<wsse:Password Type="${TokenProfile}#${type}">${escapeText(passwordValue)}</wsse:Password>`,
|
|
37
|
+
`<wsse:Nonce EncodingType="${Base64Binary}">${Buffer.from(material.nonce).toString("base64")}</wsse:Nonce>`,
|
|
38
|
+
`<wsu:Created>${material.created}</wsu:Created>`,
|
|
39
|
+
"</wsse:UsernameToken>",
|
|
40
|
+
"</wsse:Security>"
|
|
41
|
+
].join(""));
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Wrap a body element (serialized XML, carrying its own namespace
|
|
45
|
+
* declarations) in an envelope. The header, when present, is secret
|
|
46
|
+
* material and so is the whole envelope that includes it.
|
|
47
|
+
*/
|
|
48
|
+
export const buildEnvelope = (version, body, header) => Redacted.make([
|
|
49
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
50
|
+
`<soapenv:Envelope xmlns:soapenv="${envelopeNamespace(version)}">`,
|
|
51
|
+
header === undefined ? "" : `<soapenv:Header>${Redacted.value(header)}</soapenv:Header>`,
|
|
52
|
+
`<soapenv:Body>${body}</soapenv:Body>`,
|
|
53
|
+
"</soapenv:Envelope>"
|
|
54
|
+
].join(""));
|
|
55
|
+
export class EnvelopeError extends Schema.TaggedError()("EnvelopeError", {
|
|
56
|
+
detail: Schema.String
|
|
57
|
+
}) {
|
|
58
|
+
get message() {
|
|
59
|
+
return `SOAP envelope: ${this.detail}`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export class SoapFault extends Schema.Class("SoapFault")({
|
|
63
|
+
code: Schema.String,
|
|
64
|
+
reason: Schema.String,
|
|
65
|
+
/** Local name of the detail's first element, e.g. `ServizioFault`. */
|
|
66
|
+
detailElement: Schema.optionalKey(Schema.String)
|
|
67
|
+
}) {
|
|
68
|
+
}
|
|
69
|
+
export const readEnvelope = (root) => Effect.suspend(() => {
|
|
70
|
+
const read = readEnvelopeSync(root);
|
|
71
|
+
return read instanceof EnvelopeError ? Effect.fail(read) : Effect.succeed(read);
|
|
72
|
+
});
|
|
73
|
+
const readEnvelopeSync = (root) => {
|
|
74
|
+
const version = is(root, Soap11Namespace, "Envelope")
|
|
75
|
+
? "1.1"
|
|
76
|
+
: is(root, Soap12Namespace, "Envelope")
|
|
77
|
+
? "1.2"
|
|
78
|
+
: undefined;
|
|
79
|
+
if (version === undefined) {
|
|
80
|
+
return new EnvelopeError({
|
|
81
|
+
detail: `root is {${root.name.namespace}}${root.name.local}, not a SOAP Envelope`
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const namespace = envelopeNamespace(version);
|
|
85
|
+
const body = firstChild(root, namespace, "Body");
|
|
86
|
+
if (body === undefined)
|
|
87
|
+
return new EnvelopeError({ detail: "envelope has no Body" });
|
|
88
|
+
const payload = elements(body)[0];
|
|
89
|
+
if (payload === undefined || !is(payload, namespace, "Fault")) {
|
|
90
|
+
return { version, payload, fault: undefined };
|
|
91
|
+
}
|
|
92
|
+
const unqualified = (local) => elements(payload).find((child) => child.name.local === local);
|
|
93
|
+
const deep = (element, ...path) => path.reduce((current, local) => current === undefined
|
|
94
|
+
? undefined
|
|
95
|
+
: elements(current).find((child) => child.name.local === local), element);
|
|
96
|
+
const fault = version === "1.1"
|
|
97
|
+
? new SoapFault({
|
|
98
|
+
code: optionalText(unqualified("faultcode")),
|
|
99
|
+
reason: optionalText(unqualified("faultstring")),
|
|
100
|
+
...detailOf(unqualified("detail"))
|
|
101
|
+
})
|
|
102
|
+
: new SoapFault({
|
|
103
|
+
code: optionalText(deep(payload, "Code", "Value")),
|
|
104
|
+
reason: optionalText(deep(payload, "Reason", "Text")),
|
|
105
|
+
...detailOf(unqualified("Detail"))
|
|
106
|
+
});
|
|
107
|
+
return { version, payload, fault };
|
|
108
|
+
};
|
|
109
|
+
/** A fault part's text; absent parts are empty, never the whole fault's text. */
|
|
110
|
+
const optionalText = (element) => element === undefined ? "" : textOf(element).trim();
|
|
111
|
+
const detailOf = (detail) => {
|
|
112
|
+
const first = detail === undefined ? undefined : elements(detail)[0];
|
|
113
|
+
return first === undefined ? {} : { detailElement: first.name.local };
|
|
114
|
+
};
|
|
115
|
+
/** Remove every WS-Security header block (any version of the wsse namespace). */
|
|
116
|
+
export const stripSecurity = (root) => {
|
|
117
|
+
const isSecurity = (node) => node._tag === "element" &&
|
|
118
|
+
node.name.local === "Security" &&
|
|
119
|
+
/wss|secext/i.test(node.name.namespace);
|
|
120
|
+
const walk = (element) => ({
|
|
121
|
+
...element,
|
|
122
|
+
children: element.children
|
|
123
|
+
.filter((child) => !isSecurity(child))
|
|
124
|
+
.map((child) => (child._tag === "element" ? walk(child) : child))
|
|
125
|
+
});
|
|
126
|
+
return walk(root);
|
|
127
|
+
};
|
|
128
|
+
const sensitiveHeader = /^(authorization|proxy-authorization|cookie|set-cookie|x-api-key)$|token|secret|password|session|auth/i;
|
|
129
|
+
/** Response and request headers safe to persist. */
|
|
130
|
+
export const safeHeaders = (headers) => Object.fromEntries(Object.entries(headers)
|
|
131
|
+
.filter(([name]) => !sensitiveHeader.test(name))
|
|
132
|
+
.map(([name, value]) => [name.toLowerCase(), value]));
|