@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,497 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import { isSuccessCode, schemaPaths } from "./Analysis.js";
|
|
4
|
+
import { ElementDecl, elementByName, localName, operationByName } from "./Catalog.js";
|
|
5
|
+
import { effectiveClass } from "./Classification.js";
|
|
6
|
+
import { simpleView } from "./Instance.js";
|
|
7
|
+
// The REST design: a typed overlay on the 1:1 mapping. Every endpoint names
|
|
8
|
+
// the SOAP operations it is built from; every model binds to an XSD type or
|
|
9
|
+
// element (`sourceType`) and every property names the path it comes from
|
|
10
|
+
// within it; every error names the outcome codes and faults it maps. The
|
|
11
|
+
// design lives in `design/api-design.md` as a ```json apidesign block
|
|
12
|
+
// under a `Status: proposed | approved` line; editing the block is the
|
|
13
|
+
// review, flipping the status is the approval, and the OpenAPI contract is
|
|
14
|
+
// projected from it, never written by hand. `checkDesign` is the
|
|
15
|
+
// deterministic reviewer: coverage, verbs against confirmed classes, style,
|
|
16
|
+
// source paths, enum maps against declared and observed values, and
|
|
17
|
+
// errors against observed outcomes.
|
|
18
|
+
export const JsonType = Schema.Literals([
|
|
19
|
+
"string",
|
|
20
|
+
"integer",
|
|
21
|
+
"number",
|
|
22
|
+
"boolean",
|
|
23
|
+
"object",
|
|
24
|
+
"array"
|
|
25
|
+
]);
|
|
26
|
+
export class Property extends Schema.Class("Property")({
|
|
27
|
+
name: Schema.String,
|
|
28
|
+
type: JsonType,
|
|
29
|
+
format: Schema.optionalKey(Schema.String),
|
|
30
|
+
description: Schema.optionalKey(Schema.String),
|
|
31
|
+
required: Schema.Boolean,
|
|
32
|
+
nullable: Schema.optionalKey(Schema.Boolean),
|
|
33
|
+
enum: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
34
|
+
/** SOAP value → REST value; must cover every declared and observed SOAP value. */
|
|
35
|
+
enumMap: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
|
|
36
|
+
/** Model name for `object`, or for the items of an `array`. */
|
|
37
|
+
ref: Schema.optionalKey(Schema.String),
|
|
38
|
+
/** Item type of an `array` of scalars. */
|
|
39
|
+
items: Schema.optionalKey(JsonType),
|
|
40
|
+
/** XSD path relative to the model's sourceType (`saldo.valore`, `conto[]`). */
|
|
41
|
+
source: Schema.optionalKey(Schema.String),
|
|
42
|
+
/** How a property without a single source is computed. */
|
|
43
|
+
derivation: Schema.optionalKey(Schema.String)
|
|
44
|
+
}) {
|
|
45
|
+
}
|
|
46
|
+
export class Model extends Schema.Class("Model")({
|
|
47
|
+
name: Schema.String,
|
|
48
|
+
description: Schema.optionalKey(Schema.String),
|
|
49
|
+
/** XSD element or type (local name) the property sources are relative to. */
|
|
50
|
+
sourceType: Schema.optionalKey(Schema.String),
|
|
51
|
+
properties: Schema.Array(Property)
|
|
52
|
+
}) {
|
|
53
|
+
}
|
|
54
|
+
export class Parameter extends Schema.Class("Parameter")({
|
|
55
|
+
name: Schema.String,
|
|
56
|
+
in: Schema.Literals(["path", "query", "header"]),
|
|
57
|
+
required: Schema.Boolean,
|
|
58
|
+
type: JsonType,
|
|
59
|
+
format: Schema.optionalKey(Schema.String),
|
|
60
|
+
description: Schema.optionalKey(Schema.String),
|
|
61
|
+
enum: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
62
|
+
enumMap: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
|
|
63
|
+
/** XSD path in the request of the endpoint's first SOAP operation. */
|
|
64
|
+
source: Schema.optionalKey(Schema.String),
|
|
65
|
+
derivation: Schema.optionalKey(Schema.String)
|
|
66
|
+
}) {
|
|
67
|
+
}
|
|
68
|
+
export class DesignResponse extends Schema.Class("DesignResponse")({
|
|
69
|
+
status: Schema.Int,
|
|
70
|
+
description: Schema.String,
|
|
71
|
+
model: Schema.optionalKey(Schema.String),
|
|
72
|
+
/** A collection: `{ items: [...], page: {...} }`. */
|
|
73
|
+
list: Schema.optionalKey(Schema.Boolean),
|
|
74
|
+
paged: Schema.optionalKey(Schema.Boolean),
|
|
75
|
+
/** XSD path in the SOAP response the model instance(s) come from; `""` is the whole response. */
|
|
76
|
+
source: Schema.optionalKey(Schema.String)
|
|
77
|
+
}) {
|
|
78
|
+
}
|
|
79
|
+
export class ErrorMapping extends Schema.Class("ErrorMapping")({
|
|
80
|
+
status: Schema.Int,
|
|
81
|
+
/** Stable problem code, e.g. `invalid-otp`. */
|
|
82
|
+
code: Schema.String,
|
|
83
|
+
title: Schema.String,
|
|
84
|
+
/** Outcome codes (`KO17`), fault detail elements or codes (`ServizioFault`), or `transport`. */
|
|
85
|
+
from: Schema.Array(Schema.String)
|
|
86
|
+
}) {
|
|
87
|
+
}
|
|
88
|
+
export class Endpoint extends Schema.Class("Endpoint")({
|
|
89
|
+
method: Schema.Literals(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
|
90
|
+
path: Schema.String,
|
|
91
|
+
operationId: Schema.String,
|
|
92
|
+
summary: Schema.String,
|
|
93
|
+
description: Schema.optionalKey(Schema.String),
|
|
94
|
+
/** SOAP operations this endpoint is built from, in call order. */
|
|
95
|
+
sources: Schema.Array(Schema.String),
|
|
96
|
+
parameters: Schema.Array(Parameter),
|
|
97
|
+
requestBody: Schema.optionalKey(Schema.Struct({ model: Schema.String, required: Schema.Boolean })),
|
|
98
|
+
responses: Schema.Array(DesignResponse),
|
|
99
|
+
errors: Schema.Array(ErrorMapping),
|
|
100
|
+
/** Analysis findings this endpoint's shape answers, e.g. `analysis/cercaConti: SOSPESO`. */
|
|
101
|
+
evidence: Schema.Array(Schema.String)
|
|
102
|
+
}) {
|
|
103
|
+
}
|
|
104
|
+
export class Exclusion extends Schema.Class("Exclusion")({
|
|
105
|
+
operation: Schema.String,
|
|
106
|
+
reason: Schema.String
|
|
107
|
+
}) {
|
|
108
|
+
}
|
|
109
|
+
export class ApiDesign extends Schema.Class("ApiDesign")({
|
|
110
|
+
version: Schema.Literal(1),
|
|
111
|
+
title: Schema.String,
|
|
112
|
+
basePath: Schema.String,
|
|
113
|
+
endpoints: Schema.Array(Endpoint),
|
|
114
|
+
models: Schema.Array(Model),
|
|
115
|
+
excluded: Schema.Array(Exclusion),
|
|
116
|
+
notes: Schema.optionalKey(Schema.Array(Schema.String))
|
|
117
|
+
}) {
|
|
118
|
+
}
|
|
119
|
+
export class DesignFileError extends Schema.TaggedError()("DesignFileError", {
|
|
120
|
+
detail: Schema.String
|
|
121
|
+
}) {
|
|
122
|
+
get message() {
|
|
123
|
+
return `api-design.md: ${this.detail}`;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const blockPattern = /```json apidesign\s*\n([\s\S]*?)\n```/;
|
|
127
|
+
export const parseDesignFile = (text) => Effect.gen(function* () {
|
|
128
|
+
const status = /^Status:\s*(proposed|approved)\s*$/im.exec(text)?.[1]?.toLowerCase();
|
|
129
|
+
if (status === undefined)
|
|
130
|
+
return yield* new DesignFileError({ detail: "missing Status: proposed | approved" });
|
|
131
|
+
const block = blockPattern.exec(text)?.[1];
|
|
132
|
+
if (block === undefined)
|
|
133
|
+
return yield* new DesignFileError({ detail: "missing the ```json apidesign block" });
|
|
134
|
+
const design = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ApiDesign))(block).pipe(Effect.mapError((error) => new DesignFileError({ detail: `the apidesign block does not decode: ${error.message}` })));
|
|
135
|
+
return { approved: status === "approved", design };
|
|
136
|
+
});
|
|
137
|
+
export const renderDesignFile = (design, issues, status = "proposed") => {
|
|
138
|
+
const endpointLines = design.endpoints.map((endpoint) => `| ${endpoint.method} | ${design.basePath}${endpoint.path} | ${endpoint.sources.join(", ")} | ${endpoint.summary} |`);
|
|
139
|
+
return [
|
|
140
|
+
`# REST design: ${design.title}`,
|
|
141
|
+
"",
|
|
142
|
+
`Status: ${status}`,
|
|
143
|
+
"",
|
|
144
|
+
"Review the design below: edit the JSON block (it is the design of record), rerun",
|
|
145
|
+
'`soap-design "check"` until it reports no errors, then set the status to `approved`.',
|
|
146
|
+
'`soap-design "openapi"` projects the contract from an approved design only.',
|
|
147
|
+
"",
|
|
148
|
+
"## Endpoints",
|
|
149
|
+
"",
|
|
150
|
+
"| method | path | SOAP operations | summary |",
|
|
151
|
+
"| --- | --- | --- | --- |",
|
|
152
|
+
...endpointLines,
|
|
153
|
+
"",
|
|
154
|
+
...(design.excluded.length === 0
|
|
155
|
+
? []
|
|
156
|
+
: [
|
|
157
|
+
"## Excluded operations",
|
|
158
|
+
"",
|
|
159
|
+
...design.excluded.map((exclusion) => `- ${exclusion.operation}: ${exclusion.reason}`),
|
|
160
|
+
""
|
|
161
|
+
]),
|
|
162
|
+
...(design.notes === undefined || design.notes.length === 0
|
|
163
|
+
? []
|
|
164
|
+
: ["## Notes", "", ...design.notes.map((note) => `- ${note}`), ""]),
|
|
165
|
+
"## Check",
|
|
166
|
+
"",
|
|
167
|
+
...(issues.length === 0
|
|
168
|
+
? ["No issues."]
|
|
169
|
+
: issues.map((issue) => `- **${issue.severity}** ${issue.where}: ${issue.detail}`)),
|
|
170
|
+
"",
|
|
171
|
+
"## Design of record",
|
|
172
|
+
"",
|
|
173
|
+
"```json apidesign",
|
|
174
|
+
JSON.stringify(Schema.encodeSync(ApiDesign)(design), null, 2),
|
|
175
|
+
"```",
|
|
176
|
+
""
|
|
177
|
+
].join("\n");
|
|
178
|
+
};
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Checking
|
|
181
|
+
/** Resolve a model's sourceType: an element (its type) or a type, by local name. */
|
|
182
|
+
export const resolveSourceType = (catalog, name) => {
|
|
183
|
+
const element = catalog.elements.find((candidate) => localName(candidate.name) === name);
|
|
184
|
+
if (element !== undefined)
|
|
185
|
+
return element;
|
|
186
|
+
const type = catalog.types.find((candidate) => localName(candidate.name) === name && !candidate.anonymous);
|
|
187
|
+
// A type is wrapped as a pseudo-element so the path walk can start from it.
|
|
188
|
+
return type === undefined
|
|
189
|
+
? undefined
|
|
190
|
+
: new ElementDecl({ name: type.name, type: type.name, nillable: false });
|
|
191
|
+
};
|
|
192
|
+
const camel = /^[a-z][a-zA-Z0-9]*$/;
|
|
193
|
+
const segment = /^([a-z0-9]+(-[a-z0-9]+)*|\{[a-z][a-zA-Z0-9]*\})$/;
|
|
194
|
+
const pathInfo = (catalog, element) => {
|
|
195
|
+
const paths = schemaPaths(catalog, element);
|
|
196
|
+
return new Map(paths.map((path) => [path.path, path]));
|
|
197
|
+
};
|
|
198
|
+
const leafType = (catalog, element, path) => schemaPaths(catalog, element).find((candidate) => candidate.path === path)?.type;
|
|
199
|
+
export const checkDesign = (design, context) => {
|
|
200
|
+
const { catalog } = context;
|
|
201
|
+
const issues = [];
|
|
202
|
+
const error = (where, detail) => issues.push({ severity: "error", where, detail });
|
|
203
|
+
const warn = (where, detail) => issues.push({ severity: "warning", where, detail });
|
|
204
|
+
const models = new Map(design.models.map((model) => [model.name, model]));
|
|
205
|
+
if (!/^\/v\d+$/.test(design.basePath))
|
|
206
|
+
error("basePath", `expected /v<major>, found ${design.basePath}`);
|
|
207
|
+
// Coverage: every SOAP operation exposed, merged, or excluded with a reason.
|
|
208
|
+
const covered = new Set(design.endpoints.flatMap((endpoint) => endpoint.sources));
|
|
209
|
+
for (const exclusion of design.excluded) {
|
|
210
|
+
if (operationByName(catalog, exclusion.operation) === undefined)
|
|
211
|
+
error(`excluded ${exclusion.operation}`, "not an operation of the service");
|
|
212
|
+
if (exclusion.reason.trim() === "")
|
|
213
|
+
error(`excluded ${exclusion.operation}`, "needs a reason");
|
|
214
|
+
covered.add(exclusion.operation);
|
|
215
|
+
}
|
|
216
|
+
for (const operation of catalog.operations) {
|
|
217
|
+
if (!covered.has(operation.name))
|
|
218
|
+
error(operation.name, "not exposed by any endpoint and not excluded");
|
|
219
|
+
}
|
|
220
|
+
// Enum values the SOAP side can produce: declared, plus observed in samples.
|
|
221
|
+
const observed = (operation, path) => context.analyses
|
|
222
|
+
.filter((analysis) => analysis.operation === operation)
|
|
223
|
+
.flatMap((analysis) => analysis.fields
|
|
224
|
+
.filter((field) => field.path === path)
|
|
225
|
+
.flatMap((field) => field.values.map((entry) => entry.value)));
|
|
226
|
+
const checkEnumMap = (where, enumValues, enumMap, type, seen) => {
|
|
227
|
+
if (enumMap === undefined)
|
|
228
|
+
return;
|
|
229
|
+
const soapValues = [
|
|
230
|
+
...new Set([
|
|
231
|
+
...(type === undefined
|
|
232
|
+
? []
|
|
233
|
+
: (simpleView(catalog, type).facets.find((facets) => facets.enumeration !== undefined)
|
|
234
|
+
?.enumeration ?? [])),
|
|
235
|
+
...seen
|
|
236
|
+
])
|
|
237
|
+
];
|
|
238
|
+
for (const value of soapValues) {
|
|
239
|
+
if (!(value in enumMap))
|
|
240
|
+
error(where, `enumMap has no entry for SOAP value ${value}${seen.includes(value) ? " (seen in samples)" : ""}`);
|
|
241
|
+
}
|
|
242
|
+
if (enumValues !== undefined) {
|
|
243
|
+
for (const target of Object.values(enumMap)) {
|
|
244
|
+
if (!enumValues.includes(target))
|
|
245
|
+
error(where, `enumMap target ${target} is not in enum`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
// Models: names, property style, sources within the bound XSD type.
|
|
250
|
+
const modelUse = new Map(); // model → SOAP operations whose responses it renders
|
|
251
|
+
// Names the OpenAPI projection generates itself.
|
|
252
|
+
const generated = new Set([
|
|
253
|
+
"Problem",
|
|
254
|
+
"PageInfo",
|
|
255
|
+
...design.endpoints.flatMap((endpoint) => endpoint.responses.flatMap((response) => response.list === true && response.model !== undefined
|
|
256
|
+
? [`${response.model}${response.paged === true ? "Page" : "List"}`]
|
|
257
|
+
: []))
|
|
258
|
+
]);
|
|
259
|
+
for (const model of design.models) {
|
|
260
|
+
const where = `model ${model.name}`;
|
|
261
|
+
if (generated.has(model.name))
|
|
262
|
+
error(where, "the name is taken by a schema the OpenAPI projection generates (Problem, PageInfo, <Model>List, <Model>Page)");
|
|
263
|
+
if (!/^[A-Z][A-Za-z0-9]*$/.test(model.name))
|
|
264
|
+
error(where, "model names are PascalCase");
|
|
265
|
+
const bound = model.sourceType === undefined ? undefined : resolveSourceType(catalog, model.sourceType);
|
|
266
|
+
if (model.sourceType !== undefined && bound === undefined)
|
|
267
|
+
error(where, `sourceType ${model.sourceType} is not an element or named type`);
|
|
268
|
+
const paths = bound === undefined ? undefined : pathInfo(catalog, bound);
|
|
269
|
+
for (const property of model.properties) {
|
|
270
|
+
const at = `${where}.${property.name}`;
|
|
271
|
+
if (!camel.test(property.name))
|
|
272
|
+
error(at, "property names are camelCase");
|
|
273
|
+
if (/^(esito|codiceEsito|returnCode)$/i.test(property.name))
|
|
274
|
+
error(at, "the esito envelope does not belong in REST payloads; success is the HTTP status");
|
|
275
|
+
if ((property.type === "object" ||
|
|
276
|
+
(property.type === "array" && property.items === undefined)) &&
|
|
277
|
+
(property.ref === undefined || !models.has(property.ref))) {
|
|
278
|
+
error(at, `${property.type} needs ref to a defined model`);
|
|
279
|
+
}
|
|
280
|
+
if (property.source === undefined && property.derivation === undefined)
|
|
281
|
+
warn(at, "no source and no derivation: where does the value come from?");
|
|
282
|
+
if (property.source !== undefined && paths !== undefined) {
|
|
283
|
+
const found = paths.get(property.source);
|
|
284
|
+
if (found === undefined)
|
|
285
|
+
error(at, `source ${property.source} is not a path of ${model.sourceType ?? ""}`);
|
|
286
|
+
else {
|
|
287
|
+
if (property.required && found.optional) {
|
|
288
|
+
warn(at, `required in REST but optional in the XSD (${property.source}); the analysis must show it is always present, or make it optional`);
|
|
289
|
+
}
|
|
290
|
+
const seen = design.endpoints
|
|
291
|
+
.flatMap((endpoint) => endpoint.sources)
|
|
292
|
+
.flatMap((operation) => {
|
|
293
|
+
const responsePath = responsePathFor(design, model.name, operation);
|
|
294
|
+
return responsePath === undefined
|
|
295
|
+
? []
|
|
296
|
+
: observed(operation, `${responsePath}${responsePath === "" ? "" : "."}${property.source}`.replace(/^\./, ""));
|
|
297
|
+
});
|
|
298
|
+
checkEnumMap(at, property.enum, property.enumMap, bound === undefined ? undefined : leafType(catalog, bound, property.source), seen);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
else if (property.source !== undefined && model.sourceType === undefined) {
|
|
302
|
+
error(at, "a source needs the model's sourceType");
|
|
303
|
+
}
|
|
304
|
+
if (property.type === "string" &&
|
|
305
|
+
property.format === "decimal" &&
|
|
306
|
+
/amount|importo|saldo|balance/i.test(property.name) &&
|
|
307
|
+
property.ref === undefined) {
|
|
308
|
+
// Amounts travel as { amount, currency } objects (style guide); a bare decimal is fine inside Amount itself.
|
|
309
|
+
if (model.properties.every((other) => !/currency|divisa/i.test(other.name)))
|
|
310
|
+
warn(at, "amounts are { amount, currency } objects in this style guide");
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
modelUse.set(model.name, new Set());
|
|
314
|
+
}
|
|
315
|
+
// Endpoints.
|
|
316
|
+
const operationIds = new Set();
|
|
317
|
+
const routes = new Set();
|
|
318
|
+
for (const endpoint of design.endpoints) {
|
|
319
|
+
const where = `${endpoint.method} ${endpoint.path}`;
|
|
320
|
+
if (operationIds.has(endpoint.operationId))
|
|
321
|
+
error(where, `duplicate operationId ${endpoint.operationId}`);
|
|
322
|
+
operationIds.add(endpoint.operationId);
|
|
323
|
+
if (!camel.test(endpoint.operationId))
|
|
324
|
+
error(where, "operationId is camelCase");
|
|
325
|
+
// /a/{id} and /a/{other} are one route to OpenAPI and to ACE.
|
|
326
|
+
const route = `${endpoint.method} ${endpoint.path.replace(/\{[^}]+\}/g, "{}")}`;
|
|
327
|
+
if (routes.has(route))
|
|
328
|
+
error(where, "duplicate route (paths differing only in parameter names are one route)");
|
|
329
|
+
routes.add(route);
|
|
330
|
+
if (/^\/\{/.test(endpoint.path))
|
|
331
|
+
error(where, "the first path segment names the resource; it cannot be a parameter");
|
|
332
|
+
const segments = endpoint.path.split("/").slice(1);
|
|
333
|
+
if (!endpoint.path.startsWith("/") || segments.some((part) => !segment.test(part))) {
|
|
334
|
+
error(where, "path segments are kebab-case nouns or {camelCaseParam}");
|
|
335
|
+
}
|
|
336
|
+
const pathParams = segments
|
|
337
|
+
.filter((part) => part.startsWith("{"))
|
|
338
|
+
.map((part) => part.slice(1, -1));
|
|
339
|
+
for (const name of pathParams) {
|
|
340
|
+
if (!endpoint.parameters.some((parameter) => parameter.in === "path" && parameter.name === name))
|
|
341
|
+
error(where, `path parameter {${name}} is not declared`);
|
|
342
|
+
}
|
|
343
|
+
for (const parameter of endpoint.parameters.filter((parameter) => parameter.in === "path")) {
|
|
344
|
+
if (!pathParams.includes(parameter.name))
|
|
345
|
+
error(where, `parameter ${parameter.name} is in: path but not in the path`);
|
|
346
|
+
if (!parameter.required)
|
|
347
|
+
error(where, `path parameter ${parameter.name} must be required`);
|
|
348
|
+
}
|
|
349
|
+
if (endpoint.sources.length === 0)
|
|
350
|
+
error(where, "names no SOAP operation");
|
|
351
|
+
const soap = endpoint.sources.map((name) => operationByName(catalog, name));
|
|
352
|
+
endpoint.sources.forEach((name, index) => {
|
|
353
|
+
if (soap[index] === undefined)
|
|
354
|
+
error(where, `source ${name} is not an operation of the service`);
|
|
355
|
+
});
|
|
356
|
+
const classes = endpoint.sources.map((name) => effectiveClass(context.operations, name));
|
|
357
|
+
if (classes.includes("unclassified"))
|
|
358
|
+
warn(where, "a source operation is unclassified; confirm operations.md so verbs can be checked");
|
|
359
|
+
if (endpoint.method === "GET" && classes.includes("mutating"))
|
|
360
|
+
error(where, "GET must not call a mutating operation");
|
|
361
|
+
if (endpoint.method !== "GET" &&
|
|
362
|
+
classes.length > 0 &&
|
|
363
|
+
classes.every((value) => value === "read")) {
|
|
364
|
+
warn(where, `${endpoint.method} over read-only operations; use GET unless the query cannot be expressed in the URL`);
|
|
365
|
+
}
|
|
366
|
+
if (endpoint.method === "GET" && endpoint.requestBody !== undefined)
|
|
367
|
+
error(where, "GET has no request body");
|
|
368
|
+
const first = soap[0];
|
|
369
|
+
const input = first === undefined ? undefined : elementByName(catalog, first.input);
|
|
370
|
+
const requestPaths = input === undefined ? undefined : pathInfo(catalog, input);
|
|
371
|
+
for (const parameter of endpoint.parameters) {
|
|
372
|
+
const at = `${where} ${parameter.in} ${parameter.name}`;
|
|
373
|
+
if (parameter.in !== "header" && !camel.test(parameter.name))
|
|
374
|
+
error(at, "parameter names are camelCase");
|
|
375
|
+
if (parameter.source !== undefined && requestPaths !== undefined) {
|
|
376
|
+
const found = requestPaths.get(parameter.source);
|
|
377
|
+
if (found === undefined)
|
|
378
|
+
error(at, `source ${parameter.source} is not in the ${first?.name ?? ""} request`);
|
|
379
|
+
else {
|
|
380
|
+
if (!found.optional && !parameter.required && parameter.derivation === undefined)
|
|
381
|
+
warn(at, `optional here but required by ${first?.name ?? ""}`);
|
|
382
|
+
checkEnumMap(at, parameter.enum, parameter.enumMap, input === undefined ? undefined : leafType(catalog, input, parameter.source), []);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
else if (parameter.source === undefined && parameter.derivation === undefined) {
|
|
386
|
+
warn(at, "no source and no derivation");
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (endpoint.requestBody !== undefined && !models.has(endpoint.requestBody.model))
|
|
390
|
+
error(where, `request body model ${endpoint.requestBody.model} is not defined`);
|
|
391
|
+
const successes = endpoint.responses.filter((response) => response.status >= 200 && response.status < 300);
|
|
392
|
+
if (successes.length === 0)
|
|
393
|
+
error(where, "declares no 2xx response");
|
|
394
|
+
if (endpoint.method === "POST" &&
|
|
395
|
+
successes.every((response) => response.status === 200) &&
|
|
396
|
+
classes.includes("mutating") &&
|
|
397
|
+
/^\/[a-z-]+$/.test(endpoint.path)) {
|
|
398
|
+
warn(where, "creating POSTs answer 201 with a Location header");
|
|
399
|
+
}
|
|
400
|
+
const output = first?.output === undefined ? undefined : elementByName(catalog, first.output);
|
|
401
|
+
const responsePaths = output === undefined ? undefined : pathInfo(catalog, output);
|
|
402
|
+
for (const response of endpoint.responses) {
|
|
403
|
+
const at = `${where} ${response.status}`;
|
|
404
|
+
if (response.model === undefined)
|
|
405
|
+
continue;
|
|
406
|
+
const model = models.get(response.model);
|
|
407
|
+
if (model === undefined) {
|
|
408
|
+
error(at, `model ${response.model} is not defined`);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
for (const name of endpoint.sources)
|
|
412
|
+
modelUse.get(model.name)?.add(name);
|
|
413
|
+
if (response.source !== undefined && responsePaths !== undefined && output !== undefined) {
|
|
414
|
+
if (response.source !== "" && !responsePaths.has(response.source)) {
|
|
415
|
+
error(at, `source ${response.source} is not in the ${first?.name ?? ""} response`);
|
|
416
|
+
}
|
|
417
|
+
else if (model.sourceType !== undefined) {
|
|
418
|
+
const bound = resolveSourceType(catalog, model.sourceType);
|
|
419
|
+
const sourceTypeName = response.source === "" ? output.type : responsePaths.get(response.source)?.type;
|
|
420
|
+
if (bound !== undefined &&
|
|
421
|
+
sourceTypeName !== undefined &&
|
|
422
|
+
bound.type !== sourceTypeName) {
|
|
423
|
+
error(at, `model ${model.name} is bound to ${model.sourceType}, but ${response.source === "" ? "the response" : response.source} is ${localName(sourceTypeName).replace(/^~/, "")}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (response.list === true &&
|
|
427
|
+
response.source !== undefined &&
|
|
428
|
+
response.source !== "" &&
|
|
429
|
+
responsePaths.get(response.source)?.repeated !== true) {
|
|
430
|
+
error(at, `list response from ${response.source}, which does not repeat`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// Errors: every business outcome and fault the samples showed is mapped.
|
|
435
|
+
const mapped = new Set(endpoint.errors.flatMap((mapping) => mapping.from));
|
|
436
|
+
for (const mapping of endpoint.errors) {
|
|
437
|
+
if (mapping.status < 400)
|
|
438
|
+
error(`${where} error ${mapping.code}`, "error statuses are 4xx or 5xx");
|
|
439
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(mapping.code))
|
|
440
|
+
error(`${where} error ${mapping.code}`, "problem codes are kebab-case");
|
|
441
|
+
}
|
|
442
|
+
for (const analysis of context.analyses.filter((candidate) => endpoint.sources.includes(candidate.operation))) {
|
|
443
|
+
for (const outcome of analysis.outcomes) {
|
|
444
|
+
if (isSuccessCode(outcome.code))
|
|
445
|
+
continue;
|
|
446
|
+
if (!mapped.has(outcome.code))
|
|
447
|
+
warn(where, `outcome ${outcome.code}${outcome.description === undefined ? "" : ` (${outcome.description})`} seen in ${analysis.operation} is not mapped to an error`);
|
|
448
|
+
}
|
|
449
|
+
for (const fault of analysis.faults) {
|
|
450
|
+
const keys = [fault.detailElement, fault.code].filter((key) => key !== undefined);
|
|
451
|
+
if (!keys.some((key) => mapped.has(key)))
|
|
452
|
+
warn(where, `fault ${keys.join("/")} seen in ${analysis.operation} is not mapped to an error`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
for (const [name, users] of modelUse) {
|
|
457
|
+
const used = users.size > 0 ||
|
|
458
|
+
design.endpoints.some((endpoint) => endpoint.requestBody?.model === name) ||
|
|
459
|
+
design.models.some((model) => model.properties.some((property) => property.ref === name));
|
|
460
|
+
if (!used)
|
|
461
|
+
warn(`model ${name}`, "not used by any endpoint");
|
|
462
|
+
}
|
|
463
|
+
return issues;
|
|
464
|
+
};
|
|
465
|
+
/** The response path a model is rendered from for one SOAP operation, if an endpoint says so. */
|
|
466
|
+
const responsePathFor = (design, modelName, operation, visiting = new Set()) => {
|
|
467
|
+
// Models may refer to each other in a cycle (tree-shaped XSD types).
|
|
468
|
+
if (visiting.has(modelName))
|
|
469
|
+
return undefined;
|
|
470
|
+
const seen = new Set([...visiting, modelName]);
|
|
471
|
+
for (const endpoint of design.endpoints) {
|
|
472
|
+
if (!endpoint.sources.includes(operation))
|
|
473
|
+
continue;
|
|
474
|
+
for (const response of endpoint.responses) {
|
|
475
|
+
if (response.model === modelName && response.source !== undefined)
|
|
476
|
+
return response.source;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
// Nested models: the parent's property source joined to the parent's path.
|
|
480
|
+
for (const parent of design.models) {
|
|
481
|
+
for (const property of parent.properties) {
|
|
482
|
+
if (property.ref !== modelName || property.source === undefined)
|
|
483
|
+
continue;
|
|
484
|
+
const parentPath = responsePathFor(design, parent.name, operation, seen);
|
|
485
|
+
if (parentPath !== undefined)
|
|
486
|
+
return parentPath === "" ? property.source : `${parentPath}.${property.source}`;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return undefined;
|
|
490
|
+
};
|
|
491
|
+
export const blockingIssues = (issues) => issues.filter((issue) => issue.severity === "error");
|
|
492
|
+
export const designPaths = (directory) => ({
|
|
493
|
+
design: `${directory}/design/api-design.md`,
|
|
494
|
+
mapping: `${directory}/design/mapping.json`,
|
|
495
|
+
mappingSummary: `${directory}/design/mapping.md`,
|
|
496
|
+
openapi: `${directory}/design/openapi.yaml`
|
|
497
|
+
});
|