x-openapi-flow 1.1.3 → 1.2.1
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/README.md +52 -12
- package/bin/x-openapi-flow.js +110 -52
- package/examples/payment-api.yaml +6 -0
- package/examples/swagger-ui/index.html +3 -0
- package/examples/swagger-ui/x-openapi-flow-plugin.js +401 -20
- package/lib/validator.js +226 -5
- package/package.json +1 -1
- package/schema/flow-schema.json +14 -0
package/README.md
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
# x-openapi-flow
|
|
2
2
|
|
|
3
|
-
CLI and
|
|
3
|
+
CLI and extension contract for documenting and validating resource lifecycle workflows in OpenAPI using `x-openapi-flow`.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`x-openapi-flow` validates:
|
|
8
|
+
|
|
9
|
+
- Extension schema correctness
|
|
10
|
+
- Lifecycle graph consistency
|
|
11
|
+
- Optional quality checks for transitions and references
|
|
12
|
+
|
|
13
|
+
It also supports a sidecar workflow (`init` + `apply`) to preserve lifecycle metadata when OpenAPI files are regenerated.
|
|
4
14
|
|
|
5
15
|
## Installation
|
|
6
16
|
|
|
@@ -8,7 +18,22 @@ CLI and specification for validating the `x-openapi-flow` extension field in Ope
|
|
|
8
18
|
npm install x-openapi-flow
|
|
9
19
|
```
|
|
10
20
|
|
|
11
|
-
|
|
21
|
+
Optional mirror on GitHub Packages (default usage remains unscoped on npm):
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm config set @tiago-marques:registry https://npm.pkg.github.com
|
|
25
|
+
npm install @tiago-marques/x-openapi-flow
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
If authentication is required, include this in your `.npmrc`:
|
|
29
|
+
|
|
30
|
+
```ini
|
|
31
|
+
//npm.pkg.github.com/:_authToken=${GH_PACKAGES_TOKEN}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Use a GitHub PAT with `read:packages` (install) and `write:packages` (publish).
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
12
37
|
|
|
13
38
|
```bash
|
|
14
39
|
x-openapi-flow validate openapi.yaml
|
|
@@ -16,7 +41,7 @@ x-openapi-flow graph openapi.yaml
|
|
|
16
41
|
x-openapi-flow doctor
|
|
17
42
|
```
|
|
18
43
|
|
|
19
|
-
## Commands
|
|
44
|
+
## CLI Commands
|
|
20
45
|
|
|
21
46
|
```bash
|
|
22
47
|
x-openapi-flow validate <openapi-file> [--format pretty|json] [--profile core|relaxed|strict] [--strict-quality] [--config path]
|
|
@@ -26,20 +51,22 @@ x-openapi-flow graph <openapi-file> [--format mermaid|json]
|
|
|
26
51
|
x-openapi-flow doctor [--config path]
|
|
27
52
|
```
|
|
28
53
|
|
|
54
|
+
## Sidecar Workflow
|
|
55
|
+
|
|
29
56
|
`init` always works on an existing OpenAPI file in your repository.
|
|
30
|
-
`init` creates/synchronizes `
|
|
57
|
+
`init` creates/synchronizes `{context}-openapi-flow.(json|yaml)` as a persistent sidecar for your `x-openapi-flow` data.
|
|
31
58
|
Use `apply` to inject sidecar flows back into regenerated OpenAPI files.
|
|
32
59
|
If no OpenAPI/Swagger file exists yet, generate one first with your framework's official OpenAPI/Swagger tooling.
|
|
33
60
|
|
|
34
|
-
|
|
61
|
+
### Recommended Sequence
|
|
35
62
|
|
|
36
63
|
```bash
|
|
37
64
|
x-openapi-flow init openapi.yaml
|
|
38
|
-
# edit
|
|
65
|
+
# edit {context}-openapi-flow.(json|yaml)
|
|
39
66
|
x-openapi-flow apply openapi.yaml
|
|
40
67
|
```
|
|
41
68
|
|
|
42
|
-
##
|
|
69
|
+
## Configuration
|
|
43
70
|
|
|
44
71
|
Create `x-openapi-flow.config.json` in your project directory:
|
|
45
72
|
|
|
@@ -51,29 +78,42 @@ Create `x-openapi-flow.config.json` in your project directory:
|
|
|
51
78
|
}
|
|
52
79
|
```
|
|
53
80
|
|
|
54
|
-
##
|
|
81
|
+
## Compatibility
|
|
55
82
|
|
|
56
83
|
- OpenAPI input in `.yaml`, `.yml`, and `.json`
|
|
57
84
|
- Validation processes OAS content with the `x-openapi-flow` extension
|
|
58
85
|
|
|
59
|
-
|
|
86
|
+
## Transition Guidance Fields
|
|
60
87
|
|
|
61
88
|
- `next_operation_id`: operationId usually called for the next state transition
|
|
62
89
|
- `prerequisite_operation_ids`: operationIds expected before a transition
|
|
90
|
+
- `prerequisite_field_refs`: required field refs before transition
|
|
91
|
+
- `propagated_field_refs`: field refs used by downstream flows
|
|
63
92
|
|
|
64
|
-
|
|
93
|
+
Field reference format:
|
|
94
|
+
|
|
95
|
+
- `operationId:request.body.field`
|
|
96
|
+
- `operationId:response.<status>.body.field`
|
|
97
|
+
|
|
98
|
+
## Visualization
|
|
99
|
+
|
|
100
|
+
### Swagger UI
|
|
65
101
|
|
|
66
102
|
- There is no Swagger UI-based automated test in this repo today (tests are CLI-only).
|
|
67
103
|
- For UI interpretation of `x-openapi-flow`, use `showExtensions: true` plus the example plugin at `examples/swagger-ui/x-openapi-flow-plugin.js`.
|
|
68
104
|
- A ready HTML example is available at `examples/swagger-ui/index.html`.
|
|
105
|
+
- The plugin renders a global **Flow Overview** (Mermaid image) near the top of the docs, plus operation-level flow cards.
|
|
106
|
+
|
|
107
|
+

|
|
69
108
|
|
|
70
|
-
|
|
109
|
+
### Graph Output Example
|
|
71
110
|
|
|
72
111
|
`x-openapi-flow graph` includes transition guidance labels in Mermaid output when present (`next_operation_id`, `prerequisite_operation_ids`).
|
|
112
|
+
The `graph` command accepts both full OpenAPI files and sidecar files (`{context}-openapi-flow.(json|yaml)`).
|
|
73
113
|
|
|
74
114
|

|
|
75
115
|
|
|
76
|
-
## Repository and
|
|
116
|
+
## Repository and Documentation
|
|
77
117
|
|
|
78
118
|
- Repository: https://github.com/tiago-marques/x-openapi-flow
|
|
79
119
|
- Full guide and changelog are available in the root repository.
|
package/bin/x-openapi-flow.js
CHANGED
|
@@ -53,7 +53,7 @@ Examples:
|
|
|
53
53
|
x-openapi-flow validate examples/order-api.yaml
|
|
54
54
|
x-openapi-flow validate examples/order-api.yaml --profile relaxed
|
|
55
55
|
x-openapi-flow validate examples/order-api.yaml --strict-quality
|
|
56
|
-
x-openapi-flow init openapi.yaml --flows
|
|
56
|
+
x-openapi-flow init openapi.yaml --flows openapi-openapi-flow.yaml
|
|
57
57
|
x-openapi-flow init
|
|
58
58
|
x-openapi-flow apply openapi.yaml
|
|
59
59
|
x-openapi-flow apply openapi.yaml --out openapi.flow.yaml
|
|
@@ -386,7 +386,10 @@ function resolveFlowsPath(openApiFile, customFlowsPath) {
|
|
|
386
386
|
}
|
|
387
387
|
|
|
388
388
|
if (openApiFile) {
|
|
389
|
-
|
|
389
|
+
const parsed = path.parse(openApiFile);
|
|
390
|
+
const extension = parsed.ext.toLowerCase() === ".json" ? ".json" : ".yaml";
|
|
391
|
+
const fileName = `${parsed.name}-openapi-flow${extension}`;
|
|
392
|
+
return path.join(path.dirname(openApiFile), fileName);
|
|
390
393
|
}
|
|
391
394
|
|
|
392
395
|
return path.resolve(process.cwd(), DEFAULT_FLOWS_FILE);
|
|
@@ -406,6 +409,15 @@ function saveOpenApi(filePath, api) {
|
|
|
406
409
|
fs.writeFileSync(filePath, content, "utf8");
|
|
407
410
|
}
|
|
408
411
|
|
|
412
|
+
function buildFallbackOperationId(method, pathKey) {
|
|
413
|
+
const raw = `${method}_${pathKey}`.toLowerCase();
|
|
414
|
+
const sanitized = raw
|
|
415
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
416
|
+
.replace(/^_+|_+$/g, "");
|
|
417
|
+
|
|
418
|
+
return sanitized || "operation";
|
|
419
|
+
}
|
|
420
|
+
|
|
409
421
|
function extractOperationEntries(api) {
|
|
410
422
|
const entries = [];
|
|
411
423
|
const paths = (api && api.paths) || {};
|
|
@@ -419,13 +431,16 @@ function extractOperationEntries(api) {
|
|
|
419
431
|
}
|
|
420
432
|
|
|
421
433
|
const operationId = operation.operationId;
|
|
434
|
+
const resolvedOperationId = operationId || buildFallbackOperationId(method, pathKey);
|
|
422
435
|
const key = operationId ? `operationId:${operationId}` : `${method.toUpperCase()} ${pathKey}`;
|
|
423
436
|
|
|
424
437
|
entries.push({
|
|
425
438
|
key,
|
|
426
439
|
operationId,
|
|
440
|
+
resolvedOperationId,
|
|
427
441
|
method,
|
|
428
442
|
path: pathKey,
|
|
443
|
+
operation,
|
|
429
444
|
});
|
|
430
445
|
}
|
|
431
446
|
}
|
|
@@ -456,10 +471,22 @@ function readFlowsFile(flowsPath) {
|
|
|
456
471
|
|
|
457
472
|
function writeFlowsFile(flowsPath, flowsDoc) {
|
|
458
473
|
fs.mkdirSync(path.dirname(flowsPath), { recursive: true });
|
|
459
|
-
const content =
|
|
474
|
+
const content = flowsPath.endsWith(".json")
|
|
475
|
+
? `${JSON.stringify(flowsDoc, null, 2)}\n`
|
|
476
|
+
: yaml.dump(flowsDoc, { noRefs: true, lineWidth: -1 });
|
|
460
477
|
fs.writeFileSync(flowsPath, content, "utf8");
|
|
461
478
|
}
|
|
462
479
|
|
|
480
|
+
function buildFlowTemplate(operationId) {
|
|
481
|
+
const safeOperationId = operationId || "operation";
|
|
482
|
+
return {
|
|
483
|
+
version: "1.0",
|
|
484
|
+
id: `${safeOperationId}_FLOW_ID`,
|
|
485
|
+
current_state: `${safeOperationId}_STATE`,
|
|
486
|
+
transitions: [],
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
463
490
|
function buildOperationLookup(api) {
|
|
464
491
|
const lookupByKey = new Map();
|
|
465
492
|
const lookupByOperationId = new Map();
|
|
@@ -467,8 +494,8 @@ function buildOperationLookup(api) {
|
|
|
467
494
|
|
|
468
495
|
for (const entry of entries) {
|
|
469
496
|
lookupByKey.set(entry.key, entry);
|
|
470
|
-
if (entry.
|
|
471
|
-
lookupByOperationId.set(entry.
|
|
497
|
+
if (entry.resolvedOperationId) {
|
|
498
|
+
lookupByOperationId.set(entry.resolvedOperationId, entry);
|
|
472
499
|
}
|
|
473
500
|
}
|
|
474
501
|
|
|
@@ -476,51 +503,41 @@ function buildOperationLookup(api) {
|
|
|
476
503
|
}
|
|
477
504
|
|
|
478
505
|
function mergeFlowsWithOpenApi(api, flowsDoc) {
|
|
479
|
-
const { entries, lookupByKey
|
|
506
|
+
const { entries, lookupByKey } = buildOperationLookup(api);
|
|
480
507
|
|
|
508
|
+
const existingByOperationId = new Map();
|
|
481
509
|
const existingByKey = new Map();
|
|
482
510
|
for (const entry of flowsDoc.operations) {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
511
|
+
if (entry && entry.operationId) {
|
|
512
|
+
existingByOperationId.set(entry.operationId, entry);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const legacyKey = entry && entry.key ? entry.key : null;
|
|
516
|
+
if (legacyKey) {
|
|
517
|
+
existingByKey.set(legacyKey, entry);
|
|
486
518
|
}
|
|
487
519
|
}
|
|
488
520
|
|
|
489
521
|
const mergedOperations = [];
|
|
490
522
|
|
|
491
523
|
for (const op of entries) {
|
|
492
|
-
const existing =
|
|
493
|
-
|
|
494
|
-
mergedOperations.push({
|
|
495
|
-
...existing,
|
|
496
|
-
key: op.key,
|
|
497
|
-
operationId: op.operationId,
|
|
498
|
-
method: op.method,
|
|
499
|
-
path: op.path,
|
|
500
|
-
missing_in_openapi: false,
|
|
501
|
-
});
|
|
502
|
-
} else {
|
|
503
|
-
mergedOperations.push({
|
|
504
|
-
key: op.key,
|
|
505
|
-
operationId: op.operationId,
|
|
506
|
-
method: op.method,
|
|
507
|
-
path: op.path,
|
|
508
|
-
"x-openapi-flow": null,
|
|
509
|
-
missing_in_openapi: false,
|
|
510
|
-
});
|
|
511
|
-
}
|
|
512
|
-
}
|
|
524
|
+
const existing = (op.resolvedOperationId && existingByOperationId.get(op.resolvedOperationId))
|
|
525
|
+
|| existingByKey.get(op.key);
|
|
513
526
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
527
|
+
const openApiFlow =
|
|
528
|
+
op.operation && typeof op.operation["x-openapi-flow"] === "object"
|
|
529
|
+
? op.operation["x-openapi-flow"]
|
|
530
|
+
: null;
|
|
517
531
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
532
|
+
const sidecarFlow =
|
|
533
|
+
existing && typeof existing["x-openapi-flow"] === "object"
|
|
534
|
+
? existing["x-openapi-flow"]
|
|
535
|
+
: null;
|
|
536
|
+
|
|
537
|
+
mergedOperations.push({
|
|
538
|
+
operationId: op.resolvedOperationId,
|
|
539
|
+
"x-openapi-flow": sidecarFlow || openApiFlow || buildFlowTemplate(op.resolvedOperationId),
|
|
540
|
+
});
|
|
524
541
|
}
|
|
525
542
|
|
|
526
543
|
return {
|
|
@@ -535,12 +552,12 @@ function applyFlowsToOpenApi(api, flowsDoc) {
|
|
|
535
552
|
const { lookupByKey, lookupByOperationId } = buildOperationLookup(api);
|
|
536
553
|
|
|
537
554
|
for (const flowEntry of flowsDoc.operations || []) {
|
|
538
|
-
if (!flowEntry
|
|
555
|
+
if (!flowEntry) {
|
|
539
556
|
continue;
|
|
540
557
|
}
|
|
541
558
|
|
|
542
559
|
const flowValue = flowEntry["x-openapi-flow"];
|
|
543
|
-
if (!flowValue || typeof flowValue !== "object") {
|
|
560
|
+
if (!flowValue || typeof flowValue !== "object" || Object.keys(flowValue).length === 0) {
|
|
544
561
|
continue;
|
|
545
562
|
}
|
|
546
563
|
|
|
@@ -595,19 +612,12 @@ function runInit(parsed) {
|
|
|
595
612
|
|
|
596
613
|
const mergedFlows = mergeFlowsWithOpenApi(api, flowsDoc);
|
|
597
614
|
writeFlowsFile(flowsPath, mergedFlows);
|
|
598
|
-
const
|
|
599
|
-
saveOpenApi(targetOpenApiFile, api);
|
|
600
|
-
|
|
601
|
-
const trackedCount = mergedFlows.operations.filter((entry) => !entry.missing_in_openapi).length;
|
|
602
|
-
const orphanCount = mergedFlows.operations.filter((entry) => entry.missing_in_openapi).length;
|
|
615
|
+
const trackedCount = mergedFlows.operations.length;
|
|
603
616
|
|
|
604
617
|
console.log(`Using existing OpenAPI file: ${targetOpenApiFile}`);
|
|
605
618
|
console.log(`Flows sidecar synced: ${flowsPath}`);
|
|
606
619
|
console.log(`Tracked operations: ${trackedCount}`);
|
|
607
|
-
|
|
608
|
-
console.log(`Orphan flow entries kept in sidecar: ${orphanCount}`);
|
|
609
|
-
}
|
|
610
|
-
console.log(`Applied x-openapi-flow entries to OpenAPI: ${appliedCount}`);
|
|
620
|
+
console.log("OpenAPI source unchanged. Edit the sidecar and run apply to generate the full spec.");
|
|
611
621
|
|
|
612
622
|
console.log(`Validate now: x-openapi-flow validate ${targetOpenApiFile}`);
|
|
613
623
|
return 0;
|
|
@@ -699,8 +709,11 @@ function runDoctor(parsed) {
|
|
|
699
709
|
}
|
|
700
710
|
|
|
701
711
|
function buildMermaidGraph(filePath) {
|
|
702
|
-
const
|
|
703
|
-
|
|
712
|
+
const flows = extractFlowsForGraph(filePath);
|
|
713
|
+
if (flows.length === 0) {
|
|
714
|
+
throw new Error("No x-openapi-flow definitions found in OpenAPI or sidecar file");
|
|
715
|
+
}
|
|
716
|
+
|
|
704
717
|
const lines = ["stateDiagram-v2"];
|
|
705
718
|
const nodes = new Set();
|
|
706
719
|
const edges = [];
|
|
@@ -760,6 +773,51 @@ function buildMermaidGraph(filePath) {
|
|
|
760
773
|
};
|
|
761
774
|
}
|
|
762
775
|
|
|
776
|
+
function extractFlowsForGraph(filePath) {
|
|
777
|
+
let flows = [];
|
|
778
|
+
|
|
779
|
+
try {
|
|
780
|
+
const api = loadApi(filePath);
|
|
781
|
+
flows = extractFlows(api);
|
|
782
|
+
} catch (_err) {
|
|
783
|
+
flows = [];
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (flows.length > 0) {
|
|
787
|
+
return flows;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
791
|
+
const parsed = yaml.load(content);
|
|
792
|
+
|
|
793
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.operations)) {
|
|
794
|
+
return [];
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const sidecarFlows = [];
|
|
798
|
+
for (const operationEntry of parsed.operations) {
|
|
799
|
+
if (!operationEntry || typeof operationEntry !== "object") {
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const flow = operationEntry["x-openapi-flow"];
|
|
804
|
+
if (!flow || typeof flow !== "object") {
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (!flow.current_state) {
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
sidecarFlows.push({
|
|
813
|
+
endpoint: operationEntry.operationId || operationEntry.key || "sidecar-operation",
|
|
814
|
+
flow,
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return sidecarFlows;
|
|
819
|
+
}
|
|
820
|
+
|
|
763
821
|
function runGraph(parsed) {
|
|
764
822
|
try {
|
|
765
823
|
const graphResult = buildMermaidGraph(parsed.filePath);
|
|
@@ -22,6 +22,12 @@ paths:
|
|
|
22
22
|
- target_state: CAPTURED
|
|
23
23
|
condition: Merchant explicitly requests capture.
|
|
24
24
|
trigger_type: synchronous
|
|
25
|
+
next_operation_id: capturePayment
|
|
26
|
+
prerequisite_field_refs:
|
|
27
|
+
- createPayment:response.201.body.id
|
|
28
|
+
propagated_field_refs:
|
|
29
|
+
- createPayment:request.body.amount
|
|
30
|
+
- createPayment:request.body.currency
|
|
25
31
|
requestBody:
|
|
26
32
|
required: true
|
|
27
33
|
content:
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
|
|
16
16
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
17
17
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
|
|
18
|
+
<script>
|
|
19
|
+
window.XOpenApiFlowGraphImageUrl = "https://raw.githubusercontent.com/tiago-marques/x-openapi-flow/main/docs/assets/graph-order-guided.svg";
|
|
20
|
+
</script>
|
|
18
21
|
<script src="./x-openapi-flow-plugin.js"></script>
|
|
19
22
|
<script>
|
|
20
23
|
window.ui = SwaggerUIBundle({
|
|
@@ -1,46 +1,427 @@
|
|
|
1
1
|
window.XOpenApiFlowPlugin = function () {
|
|
2
|
+
const h = React.createElement;
|
|
3
|
+
|
|
4
|
+
function toPlain(value) {
|
|
5
|
+
if (!value) return value;
|
|
6
|
+
return value.toJS ? value.toJS() : value;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function text(value) {
|
|
10
|
+
if (value === null || value === undefined || value === "") return "-";
|
|
11
|
+
if (Array.isArray(value)) return value.length ? value.join(", ") : "-";
|
|
12
|
+
return String(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function transitionsList(currentState, transitions) {
|
|
16
|
+
if (!Array.isArray(transitions) || transitions.length === 0) {
|
|
17
|
+
return h("div", { style: { opacity: 0.85, fontStyle: "italic" } }, "No transitions (terminal state)");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return h(
|
|
21
|
+
"ul",
|
|
22
|
+
{ style: { margin: "6px 0 0 18px", padding: 0 } },
|
|
23
|
+
transitions.map((transition, index) =>
|
|
24
|
+
h(
|
|
25
|
+
"li",
|
|
26
|
+
{ key: `${currentState}-${index}`, style: { marginBottom: "4px", lineHeight: 1.45 } },
|
|
27
|
+
h("strong", null, text(transition.trigger_type)),
|
|
28
|
+
" → ",
|
|
29
|
+
h("strong", null, text(transition.target_state)),
|
|
30
|
+
transition.condition ? ` — ${text(transition.condition)}` : "",
|
|
31
|
+
transition.next_operation_id ? ` (next: ${text(transition.next_operation_id)})` : ""
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function miniGraph(currentState, transitions) {
|
|
38
|
+
if (!Array.isArray(transitions) || transitions.length === 0) {
|
|
39
|
+
return [h("div", { key: "terminal", style: { fontFamily: "monospace" } }, `${text(currentState)} [terminal]`)];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return transitions.map((transition, index) =>
|
|
43
|
+
h(
|
|
44
|
+
"div",
|
|
45
|
+
{ key: `edge-${index}`, style: { fontFamily: "monospace", lineHeight: 1.45 } },
|
|
46
|
+
`${text(currentState)} --> ${text(transition.target_state)} [${text(transition.trigger_type)}]`
|
|
47
|
+
)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
2
51
|
return {
|
|
3
52
|
wrapComponents: {
|
|
4
|
-
OperationSummary: (Original
|
|
53
|
+
OperationSummary: (Original) => (props) => {
|
|
5
54
|
const operation = props.operation;
|
|
6
55
|
const flow = operation && operation.get && operation.get("x-openapi-flow");
|
|
7
56
|
|
|
8
57
|
if (!flow) {
|
|
9
|
-
return
|
|
58
|
+
return h(Original, props);
|
|
10
59
|
}
|
|
11
60
|
|
|
12
|
-
const flowObject = flow
|
|
13
|
-
const currentState = flowObject.current_state
|
|
14
|
-
const
|
|
61
|
+
const flowObject = toPlain(flow) || {};
|
|
62
|
+
const currentState = flowObject.current_state;
|
|
63
|
+
const transitions = Array.isArray(flowObject.transitions) ? flowObject.transitions : [];
|
|
64
|
+
const graphImageUrl = flowObject.graph_image_url || window.XOpenApiFlowGraphImageUrl;
|
|
65
|
+
|
|
66
|
+
const metadataGrid = h(
|
|
67
|
+
"div",
|
|
68
|
+
{
|
|
69
|
+
style: {
|
|
70
|
+
display: "grid",
|
|
71
|
+
gridTemplateColumns: "140px 1fr",
|
|
72
|
+
gap: "4px 10px",
|
|
73
|
+
fontSize: "12px",
|
|
74
|
+
marginTop: "6px",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
h("div", { style: { opacity: 0.85 } }, "version"),
|
|
78
|
+
h("div", null, text(flowObject.version)),
|
|
79
|
+
h("div", { style: { opacity: 0.85 } }, "id"),
|
|
80
|
+
h("div", null, text(flowObject.id)),
|
|
81
|
+
h("div", { style: { opacity: 0.85 } }, "current_state"),
|
|
82
|
+
h("div", null, text(currentState))
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const graphImageNode = graphImageUrl
|
|
86
|
+
? h(
|
|
87
|
+
"div",
|
|
88
|
+
{ style: { marginTop: "10px" } },
|
|
89
|
+
h("div", { style: { fontWeight: 700, marginBottom: "6px" } }, "Flow graph image"),
|
|
90
|
+
h("img", {
|
|
91
|
+
src: graphImageUrl,
|
|
92
|
+
alt: "x-openapi-flow graph",
|
|
93
|
+
style: {
|
|
94
|
+
width: "100%",
|
|
95
|
+
maxWidth: "560px",
|
|
96
|
+
border: "1px solid rgba(255,255,255,0.3)",
|
|
97
|
+
borderRadius: "6px",
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
)
|
|
101
|
+
: null;
|
|
15
102
|
|
|
16
|
-
return
|
|
103
|
+
return h(
|
|
17
104
|
"div",
|
|
18
105
|
null,
|
|
19
|
-
|
|
20
|
-
|
|
106
|
+
h(Original, props),
|
|
107
|
+
h(
|
|
21
108
|
"div",
|
|
22
109
|
{
|
|
23
110
|
style: {
|
|
24
111
|
marginTop: "8px",
|
|
25
|
-
padding: "
|
|
26
|
-
border: "1px solid
|
|
27
|
-
borderRadius: "
|
|
28
|
-
background: "
|
|
112
|
+
padding: "10px",
|
|
113
|
+
border: "1px solid rgba(255,255,255,0.28)",
|
|
114
|
+
borderRadius: "8px",
|
|
115
|
+
background: "rgba(0,0,0,0.12)",
|
|
29
116
|
fontSize: "12px",
|
|
30
117
|
},
|
|
31
118
|
},
|
|
32
|
-
|
|
33
|
-
|
|
119
|
+
h("div", { style: { fontWeight: 700 } }, "x-openapi-flow"),
|
|
120
|
+
metadataGrid,
|
|
121
|
+
h("div", { style: { marginTop: "10px", fontWeight: 700 } }, "Transitions"),
|
|
122
|
+
transitionsList(currentState, transitions),
|
|
123
|
+
h("div", { style: { marginTop: "10px", fontWeight: 700 } }, "Flow graph (operation-level)"),
|
|
124
|
+
h(
|
|
34
125
|
"div",
|
|
35
|
-
{
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
126
|
+
{
|
|
127
|
+
style: {
|
|
128
|
+
marginTop: "6px",
|
|
129
|
+
border: "1px dashed rgba(255,255,255,0.32)",
|
|
130
|
+
borderRadius: "6px",
|
|
131
|
+
padding: "8px",
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
...miniGraph(currentState, transitions)
|
|
135
|
+
),
|
|
136
|
+
graphImageNode
|
|
41
137
|
)
|
|
42
138
|
);
|
|
43
139
|
},
|
|
44
140
|
},
|
|
45
141
|
};
|
|
46
142
|
};
|
|
143
|
+
|
|
144
|
+
(function () {
|
|
145
|
+
const styleId = 'x-openapi-flow-ui-style';
|
|
146
|
+
|
|
147
|
+
function injectStyles() {
|
|
148
|
+
if (document.getElementById(styleId)) return;
|
|
149
|
+
|
|
150
|
+
const style = document.createElement('style');
|
|
151
|
+
style.id = styleId;
|
|
152
|
+
style.textContent = `
|
|
153
|
+
.xof-card { border: 1px solid rgba(255,255,255,0.28); border-radius: 8px; padding: 10px; background: rgba(0,0,0,0.12); }
|
|
154
|
+
.xof-title { font-weight: 700; margin-bottom: 8px; }
|
|
155
|
+
.xof-meta { display: grid; grid-template-columns: 140px 1fr; gap: 4px 10px; font-size: 12px; margin-bottom: 10px; }
|
|
156
|
+
.xof-meta-label { opacity: 0.85; }
|
|
157
|
+
.xof-list { margin: 0; padding-left: 18px; }
|
|
158
|
+
.xof-list li { margin: 4px 0; }
|
|
159
|
+
.xof-graph { margin-top: 10px; padding: 8px; border: 1px dashed rgba(255,255,255,0.32); border-radius: 6px; }
|
|
160
|
+
.xof-graph-title { font-size: 12px; font-weight: 700; margin-bottom: 6px; }
|
|
161
|
+
.xof-edge { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace; font-size: 12px; line-height: 1.45; white-space: pre-wrap; }
|
|
162
|
+
.xof-empty { opacity: 0.85; font-style: italic; }
|
|
163
|
+
.xof-overview { margin: 10px 0 16px; }
|
|
164
|
+
.xof-overview img { width: 100%; max-width: 760px; border: 1px solid rgba(255,255,255,0.3); border-radius: 6px; background: #fff; }
|
|
165
|
+
.xof-overview-code { margin-top: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace; font-size: 11px; opacity: 0.9; white-space: pre-wrap; }
|
|
166
|
+
`;
|
|
167
|
+
|
|
168
|
+
document.head.appendChild(style);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function text(value) {
|
|
172
|
+
if (value === null || value === undefined || value === '') return '-';
|
|
173
|
+
if (Array.isArray(value)) return value.length ? value.join(', ') : '-';
|
|
174
|
+
return String(value);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function renderTransitions(currentState, transitions) {
|
|
178
|
+
if (!Array.isArray(transitions) || transitions.length === 0) {
|
|
179
|
+
return '<div class="xof-empty">No transitions (terminal state)</div>';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return `<ul class="xof-list">${transitions
|
|
183
|
+
.map((transition) => {
|
|
184
|
+
const condition = transition.condition ? ` — ${text(transition.condition)}` : '';
|
|
185
|
+
const nextOperation = transition.next_operation_id ? ` (next: ${text(transition.next_operation_id)})` : '';
|
|
186
|
+
return `<li><strong>${text(transition.trigger_type)}</strong> → <strong>${text(transition.target_state)}</strong>${condition}${nextOperation}</li>`;
|
|
187
|
+
})
|
|
188
|
+
.join('')}</ul>`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function renderGraph(currentState, transitions) {
|
|
192
|
+
if (!Array.isArray(transitions) || transitions.length === 0) {
|
|
193
|
+
return `<div class="xof-edge">${text(currentState)} [terminal]</div>`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return transitions
|
|
197
|
+
.map((transition) => `<div class="xof-edge">${text(currentState)} --> ${text(transition.target_state)} [${text(transition.trigger_type)}]</div>`)
|
|
198
|
+
.join('');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function renderCard(flow) {
|
|
202
|
+
const transitions = Array.isArray(flow.transitions) ? flow.transitions : [];
|
|
203
|
+
return `
|
|
204
|
+
<div class="xof-card">
|
|
205
|
+
<div class="xof-title">x-openapi-flow</div>
|
|
206
|
+
<div class="xof-meta">
|
|
207
|
+
<div class="xof-meta-label">version</div><div>${text(flow.version)}</div>
|
|
208
|
+
<div class="xof-meta-label">id</div><div>${text(flow.id)}</div>
|
|
209
|
+
<div class="xof-meta-label">current_state</div><div>${text(flow.current_state)}</div>
|
|
210
|
+
</div>
|
|
211
|
+
<div><strong>Transitions</strong></div>
|
|
212
|
+
${renderTransitions(flow.current_state, transitions)}
|
|
213
|
+
<div class="xof-graph">
|
|
214
|
+
<div class="xof-graph-title">Flow graph (operation-level)</div>
|
|
215
|
+
${renderGraph(flow.current_state, transitions)}
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function getSpecFromUi() {
|
|
222
|
+
try {
|
|
223
|
+
if (!window.ui || !window.ui.specSelectors || !window.ui.specSelectors.specJson) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const spec = window.ui.specSelectors.specJson();
|
|
228
|
+
return spec && spec.toJS ? spec.toJS() : spec;
|
|
229
|
+
} catch (_error) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function extractFlowsFromSpec(spec) {
|
|
235
|
+
const result = [];
|
|
236
|
+
const paths = (spec && spec.paths) || {};
|
|
237
|
+
const methods = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
|
|
238
|
+
|
|
239
|
+
Object.entries(paths).forEach(([pathKey, pathItem]) => {
|
|
240
|
+
if (!pathItem || typeof pathItem !== 'object') return;
|
|
241
|
+
|
|
242
|
+
methods.forEach((method) => {
|
|
243
|
+
const operation = pathItem[method];
|
|
244
|
+
if (!operation || typeof operation !== 'object') return;
|
|
245
|
+
|
|
246
|
+
const flow = operation['x-openapi-flow'];
|
|
247
|
+
if (!flow || typeof flow !== 'object' || !flow.current_state) return;
|
|
248
|
+
|
|
249
|
+
result.push({
|
|
250
|
+
operationId: operation.operationId || `${method}_${pathKey}`,
|
|
251
|
+
flow,
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function buildOverviewMermaid(flows) {
|
|
260
|
+
const lines = ['stateDiagram-v2'];
|
|
261
|
+
const states = new Set();
|
|
262
|
+
const seen = new Set();
|
|
263
|
+
|
|
264
|
+
flows.forEach(({ flow }) => {
|
|
265
|
+
const current = flow.current_state;
|
|
266
|
+
if (!current) return;
|
|
267
|
+
|
|
268
|
+
states.add(current);
|
|
269
|
+
const transitions = Array.isArray(flow.transitions) ? flow.transitions : [];
|
|
270
|
+
transitions.forEach((transition) => {
|
|
271
|
+
const target = transition.target_state;
|
|
272
|
+
if (!target) return;
|
|
273
|
+
states.add(target);
|
|
274
|
+
|
|
275
|
+
const labelParts = [];
|
|
276
|
+
if (transition.next_operation_id) {
|
|
277
|
+
labelParts.push(`next:${text(transition.next_operation_id)}`);
|
|
278
|
+
}
|
|
279
|
+
if (Array.isArray(transition.prerequisite_operation_ids) && transition.prerequisite_operation_ids.length) {
|
|
280
|
+
labelParts.push(`requires:${transition.prerequisite_operation_ids.join(',')}`);
|
|
281
|
+
}
|
|
282
|
+
const label = labelParts.join(' | ');
|
|
283
|
+
const key = `${current}::${target}::${label}`;
|
|
284
|
+
if (seen.has(key)) return;
|
|
285
|
+
seen.add(key);
|
|
286
|
+
lines.push(` ${current} --> ${target}${label ? `: ${label}` : ''}`);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
Array.from(states)
|
|
291
|
+
.sort()
|
|
292
|
+
.forEach((state) => {
|
|
293
|
+
lines.splice(1, 0, ` state ${state}`);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
return lines.join('\n');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let mermaidLoaderPromise = null;
|
|
300
|
+
function ensureMermaid() {
|
|
301
|
+
if (window.mermaid) {
|
|
302
|
+
return Promise.resolve(window.mermaid);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (mermaidLoaderPromise) {
|
|
306
|
+
return mermaidLoaderPromise;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
mermaidLoaderPromise = new Promise((resolve, reject) => {
|
|
310
|
+
const script = document.createElement('script');
|
|
311
|
+
script.src = 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js';
|
|
312
|
+
script.async = true;
|
|
313
|
+
script.onload = () => {
|
|
314
|
+
if (window.mermaid) {
|
|
315
|
+
window.mermaid.initialize({ startOnLoad: false, securityLevel: 'loose' });
|
|
316
|
+
resolve(window.mermaid);
|
|
317
|
+
} else {
|
|
318
|
+
reject(new Error('Mermaid library not available after load'));
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
script.onerror = () => reject(new Error('Could not load Mermaid library'));
|
|
322
|
+
document.head.appendChild(script);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
return mermaidLoaderPromise;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function svgToDataUri(svg) {
|
|
329
|
+
const encoded = window.btoa(unescape(encodeURIComponent(svg)));
|
|
330
|
+
return `data:image/svg+xml;base64,${encoded}`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let overviewRenderedHash = null;
|
|
334
|
+
async function renderOverview() {
|
|
335
|
+
const spec = getSpecFromUi();
|
|
336
|
+
const flows = extractFlowsFromSpec(spec);
|
|
337
|
+
if (!flows.length) return;
|
|
338
|
+
|
|
339
|
+
const mermaid = buildOverviewMermaid(flows);
|
|
340
|
+
const currentHash = `${flows.length}:${mermaid}`;
|
|
341
|
+
if (overviewRenderedHash === currentHash) return;
|
|
342
|
+
|
|
343
|
+
const infoContainer = document.querySelector('.swagger-ui .information-container');
|
|
344
|
+
if (!infoContainer) return;
|
|
345
|
+
|
|
346
|
+
let holder = document.getElementById('xof-overview-holder');
|
|
347
|
+
if (!holder) {
|
|
348
|
+
holder = document.createElement('div');
|
|
349
|
+
holder.id = 'xof-overview-holder';
|
|
350
|
+
holder.className = 'xof-overview xof-card';
|
|
351
|
+
infoContainer.parentNode.insertBefore(holder, infoContainer.nextSibling);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
holder.innerHTML = '<div class="xof-title">x-openapi-flow — Flow Overview</div><div class="xof-empty">Rendering Mermaid graph...</div>';
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
const mermaidLib = await ensureMermaid();
|
|
358
|
+
const renderId = `xof-overview-${Date.now()}`;
|
|
359
|
+
const renderResult = await mermaidLib.render(renderId, mermaid);
|
|
360
|
+
const svg = renderResult && renderResult.svg ? renderResult.svg : renderResult;
|
|
361
|
+
const dataUri = svgToDataUri(svg);
|
|
362
|
+
|
|
363
|
+
holder.innerHTML = `
|
|
364
|
+
<div class="xof-title">x-openapi-flow — Flow Overview</div>
|
|
365
|
+
<img src="${dataUri}" alt="x-openapi-flow overview graph" />
|
|
366
|
+
<details style="margin-top:8px;">
|
|
367
|
+
<summary style="cursor:pointer;">Mermaid source</summary>
|
|
368
|
+
<div class="xof-overview-code">${mermaid.replace(/</g, '<').replace(/>/g, '>')}</div>
|
|
369
|
+
</details>
|
|
370
|
+
`;
|
|
371
|
+
} catch (_error) {
|
|
372
|
+
holder.innerHTML = `
|
|
373
|
+
<div class="xof-title">x-openapi-flow — Flow Overview</div>
|
|
374
|
+
<div class="xof-empty">Could not render Mermaid image in this environment.</div>
|
|
375
|
+
<div class="xof-overview-code">${mermaid.replace(/</g, '<').replace(/>/g, '>')}</div>
|
|
376
|
+
`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
overviewRenderedHash = currentHash;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function findXOpenApiFlowValueCell(opblock) {
|
|
383
|
+
const rows = opblock.querySelectorAll('tr');
|
|
384
|
+
for (const row of rows) {
|
|
385
|
+
const cells = row.querySelectorAll('td');
|
|
386
|
+
if (cells.length < 2) continue;
|
|
387
|
+
if (cells[0].innerText.trim() === 'x-openapi-flow') {
|
|
388
|
+
return cells[1];
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function enhanceOperation(opblock) {
|
|
395
|
+
const valueCell = findXOpenApiFlowValueCell(opblock);
|
|
396
|
+
if (!valueCell || valueCell.dataset.xofEnhanced === '1') return;
|
|
397
|
+
|
|
398
|
+
const raw = valueCell.innerText.trim();
|
|
399
|
+
if (!raw) return;
|
|
400
|
+
|
|
401
|
+
let flow;
|
|
402
|
+
try {
|
|
403
|
+
flow = JSON.parse(raw);
|
|
404
|
+
} catch (_error) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
valueCell.innerHTML = renderCard(flow);
|
|
409
|
+
valueCell.dataset.xofEnhanced = '1';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function enhanceAll() {
|
|
413
|
+
injectStyles();
|
|
414
|
+
const opblocks = document.querySelectorAll('.opblock');
|
|
415
|
+
opblocks.forEach((opblock) => enhanceOperation(opblock));
|
|
416
|
+
renderOverview();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const observer = new MutationObserver(() => {
|
|
420
|
+
enhanceAll();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
window.addEventListener('load', () => {
|
|
424
|
+
enhanceAll();
|
|
425
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
426
|
+
});
|
|
427
|
+
})();
|
package/lib/validator.js
CHANGED
|
@@ -149,20 +149,42 @@ function defaultResult(pathValue, ok = true) {
|
|
|
149
149
|
duplicate_transitions: [],
|
|
150
150
|
non_terminating_states: [],
|
|
151
151
|
invalid_operation_references: [],
|
|
152
|
+
invalid_field_references: [],
|
|
152
153
|
warnings: [],
|
|
153
154
|
},
|
|
154
155
|
};
|
|
155
156
|
}
|
|
156
157
|
|
|
158
|
+
function getOperationsById(api) {
|
|
159
|
+
const operationsById = new Map();
|
|
160
|
+
const paths = (api && api.paths) || {};
|
|
161
|
+
const methods = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
|
|
162
|
+
|
|
163
|
+
for (const [pathKey, pathItem] of Object.entries(paths)) {
|
|
164
|
+
for (const method of methods) {
|
|
165
|
+
const operation = pathItem[method];
|
|
166
|
+
if (!operation || !operation.operationId) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
operationsById.set(operation.operationId, {
|
|
171
|
+
operation,
|
|
172
|
+
endpoint: `${method.toUpperCase()} ${pathKey}`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return operationsById;
|
|
178
|
+
}
|
|
179
|
+
|
|
157
180
|
/**
|
|
158
181
|
* Detect invalid operationId references declared in transitions.
|
|
182
|
+
* @param {Map<string, { operation: object, endpoint: string }>} operationsById
|
|
159
183
|
* @param {{ endpoint: string, operation_id?: string, flow: object }[]} flows
|
|
160
184
|
* @returns {{ type: string, operation_id: string, declared_in: string }[]}
|
|
161
185
|
*/
|
|
162
|
-
function detectInvalidOperationReferences(flows) {
|
|
163
|
-
const knownOperationIds = new Set(
|
|
164
|
-
flows.map(({ operation_id }) => operation_id).filter(Boolean)
|
|
165
|
-
);
|
|
186
|
+
function detectInvalidOperationReferences(operationsById, flows) {
|
|
187
|
+
const knownOperationIds = new Set(operationsById.keys());
|
|
166
188
|
|
|
167
189
|
const invalidReferences = [];
|
|
168
190
|
|
|
@@ -197,6 +219,196 @@ function detectInvalidOperationReferences(flows) {
|
|
|
197
219
|
return invalidReferences;
|
|
198
220
|
}
|
|
199
221
|
|
|
222
|
+
function parseFieldReference(refValue) {
|
|
223
|
+
if (typeof refValue !== "string") {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const match = refValue.match(/^([^:]+):(request\.body|response\.(\d{3}|default)\.body)\.(.+)$/);
|
|
228
|
+
if (!match) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const operationId = match[1];
|
|
233
|
+
const scope = match[2];
|
|
234
|
+
const responseCode = match[3];
|
|
235
|
+
const fieldPath = match[4];
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
operation_id: operationId,
|
|
239
|
+
scope,
|
|
240
|
+
response_code: responseCode,
|
|
241
|
+
field_path: fieldPath,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function resolveSchema(api, schema, depth = 0) {
|
|
246
|
+
if (!schema || typeof schema !== "object") {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (depth > 10) {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (schema.$ref && typeof schema.$ref === "string") {
|
|
255
|
+
const ref = schema.$ref;
|
|
256
|
+
if (!ref.startsWith("#/")) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const tokens = ref.slice(2).split("/");
|
|
261
|
+
let target = api;
|
|
262
|
+
for (const token of tokens) {
|
|
263
|
+
if (!target || typeof target !== "object") {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
target = target[token];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return resolveSchema(api, target, depth + 1);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return schema;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function hasFieldPath(api, schema, pathTokens) {
|
|
276
|
+
const resolved = resolveSchema(api, schema);
|
|
277
|
+
if (!resolved) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (pathTokens.length === 0) {
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const [currentToken, ...rest] = pathTokens;
|
|
286
|
+
|
|
287
|
+
if (Array.isArray(resolved.anyOf)) {
|
|
288
|
+
return resolved.anyOf.some((item) => hasFieldPath(api, item, pathTokens));
|
|
289
|
+
}
|
|
290
|
+
if (Array.isArray(resolved.oneOf)) {
|
|
291
|
+
return resolved.oneOf.some((item) => hasFieldPath(api, item, pathTokens));
|
|
292
|
+
}
|
|
293
|
+
if (Array.isArray(resolved.allOf)) {
|
|
294
|
+
return resolved.allOf.some((item) => hasFieldPath(api, item, pathTokens));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (resolved.type === "array" && resolved.items) {
|
|
298
|
+
return hasFieldPath(api, resolved.items, pathTokens);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (resolved.properties && typeof resolved.properties === "object") {
|
|
302
|
+
if (!(currentToken in resolved.properties)) {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
return hasFieldPath(api, resolved.properties[currentToken], rest);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (resolved.additionalProperties && typeof resolved.additionalProperties === "object") {
|
|
309
|
+
return hasFieldPath(api, resolved.additionalProperties, rest);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function resolveFieldReferenceSchema(api, operationsById, parsedRef) {
|
|
316
|
+
const operationInfo = operationsById.get(parsedRef.operation_id);
|
|
317
|
+
if (!operationInfo) {
|
|
318
|
+
return { error: "operation_not_found" };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const operation = operationInfo.operation;
|
|
322
|
+
if (parsedRef.scope === "request.body") {
|
|
323
|
+
const requestSchema = operation.requestBody
|
|
324
|
+
&& operation.requestBody.content
|
|
325
|
+
&& operation.requestBody.content["application/json"]
|
|
326
|
+
&& operation.requestBody.content["application/json"].schema;
|
|
327
|
+
|
|
328
|
+
if (!requestSchema) {
|
|
329
|
+
return { error: "request_schema_not_found" };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return { schema: requestSchema };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const responseCode = parsedRef.response_code;
|
|
336
|
+
const responseSchema = operation.responses
|
|
337
|
+
&& operation.responses[responseCode]
|
|
338
|
+
&& operation.responses[responseCode].content
|
|
339
|
+
&& operation.responses[responseCode].content["application/json"]
|
|
340
|
+
&& operation.responses[responseCode].content["application/json"].schema;
|
|
341
|
+
|
|
342
|
+
if (!responseSchema) {
|
|
343
|
+
return { error: "response_schema_not_found" };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return { schema: responseSchema };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function detectInvalidFieldReferences(api, operationsById, flows) {
|
|
350
|
+
const invalidFieldReferences = [];
|
|
351
|
+
|
|
352
|
+
for (const { endpoint, flow } of flows) {
|
|
353
|
+
const transitions = flow.transitions || [];
|
|
354
|
+
|
|
355
|
+
for (const transition of transitions) {
|
|
356
|
+
const referenceGroups = [
|
|
357
|
+
{
|
|
358
|
+
type: "prerequisite_field_refs",
|
|
359
|
+
refs: Array.isArray(transition.prerequisite_field_refs)
|
|
360
|
+
? transition.prerequisite_field_refs
|
|
361
|
+
: [],
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
type: "propagated_field_refs",
|
|
365
|
+
refs: Array.isArray(transition.propagated_field_refs)
|
|
366
|
+
? transition.propagated_field_refs
|
|
367
|
+
: [],
|
|
368
|
+
},
|
|
369
|
+
];
|
|
370
|
+
|
|
371
|
+
for (const group of referenceGroups) {
|
|
372
|
+
for (const refValue of group.refs) {
|
|
373
|
+
const parsedRef = parseFieldReference(refValue);
|
|
374
|
+
if (!parsedRef) {
|
|
375
|
+
invalidFieldReferences.push({
|
|
376
|
+
type: group.type,
|
|
377
|
+
reference: refValue,
|
|
378
|
+
reason: "invalid_format",
|
|
379
|
+
declared_in: endpoint,
|
|
380
|
+
});
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const resolvedSchema = resolveFieldReferenceSchema(api, operationsById, parsedRef);
|
|
385
|
+
if (resolvedSchema.error) {
|
|
386
|
+
invalidFieldReferences.push({
|
|
387
|
+
type: group.type,
|
|
388
|
+
reference: refValue,
|
|
389
|
+
reason: resolvedSchema.error,
|
|
390
|
+
declared_in: endpoint,
|
|
391
|
+
});
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const pathTokens = parsedRef.field_path.split(".").filter(Boolean);
|
|
396
|
+
if (!hasFieldPath(api, resolvedSchema.schema, pathTokens)) {
|
|
397
|
+
invalidFieldReferences.push({
|
|
398
|
+
type: group.type,
|
|
399
|
+
reference: refValue,
|
|
400
|
+
reason: "field_not_found",
|
|
401
|
+
declared_in: endpoint,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return invalidFieldReferences;
|
|
410
|
+
}
|
|
411
|
+
|
|
200
412
|
/**
|
|
201
413
|
* Verify that every target_state referenced in transitions corresponds to a
|
|
202
414
|
* current_state defined in at least one endpoint of the same API.
|
|
@@ -588,6 +800,7 @@ function run(apiPath, options = {}) {
|
|
|
588
800
|
}
|
|
589
801
|
|
|
590
802
|
// 5. Advanced graph checks
|
|
803
|
+
const operationsById = getOperationsById(api);
|
|
591
804
|
const graph = buildStateGraph(flows);
|
|
592
805
|
const initialStates = [...graph.nodes].filter(
|
|
593
806
|
(state) => graph.indegree.get(state) === 0
|
|
@@ -599,7 +812,8 @@ function run(apiPath, options = {}) {
|
|
|
599
812
|
const cycle = detectCycle(graph);
|
|
600
813
|
const duplicateTransitions = detectDuplicateTransitions(flows);
|
|
601
814
|
const terminalCoverage = detectTerminalCoverage(graph);
|
|
602
|
-
const invalidOperationReferences = detectInvalidOperationReferences(flows);
|
|
815
|
+
const invalidOperationReferences = detectInvalidOperationReferences(operationsById, flows);
|
|
816
|
+
const invalidFieldReferences = detectInvalidFieldReferences(api, operationsById, flows);
|
|
603
817
|
const multipleInitialStates = initialStates.length > 1 ? initialStates : [];
|
|
604
818
|
|
|
605
819
|
if (profileConfig.runAdvanced) {
|
|
@@ -645,6 +859,12 @@ function run(apiPath, options = {}) {
|
|
|
645
859
|
);
|
|
646
860
|
}
|
|
647
861
|
|
|
862
|
+
if (profileConfig.runQuality && invalidFieldReferences.length > 0) {
|
|
863
|
+
qualityWarnings.push(
|
|
864
|
+
`Transition field references not found/invalid: ${invalidFieldReferences.length}`
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
648
868
|
if (strictQuality && qualityWarnings.length > 0) {
|
|
649
869
|
hasErrors = true;
|
|
650
870
|
}
|
|
@@ -734,6 +954,7 @@ function run(apiPath, options = {}) {
|
|
|
734
954
|
duplicate_transitions: duplicateTransitions,
|
|
735
955
|
non_terminating_states: terminalCoverage.non_terminating_states,
|
|
736
956
|
invalid_operation_references: invalidOperationReferences,
|
|
957
|
+
invalid_field_references: invalidFieldReferences,
|
|
737
958
|
warnings: qualityWarnings,
|
|
738
959
|
},
|
|
739
960
|
};
|
package/package.json
CHANGED
package/schema/flow-schema.json
CHANGED
|
@@ -69,6 +69,20 @@
|
|
|
69
69
|
"items": {
|
|
70
70
|
"type": "string"
|
|
71
71
|
}
|
|
72
|
+
},
|
|
73
|
+
"prerequisite_field_refs": {
|
|
74
|
+
"type": "array",
|
|
75
|
+
"description": "Field references required before this transition. Format: operationId:request.body.field or operationId:response.<status>.body.field",
|
|
76
|
+
"items": {
|
|
77
|
+
"type": "string"
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"propagated_field_refs": {
|
|
81
|
+
"type": "array",
|
|
82
|
+
"description": "Field references produced/propagated for downstream flows. Format: operationId:request.body.field or operationId:response.<status>.body.field",
|
|
83
|
+
"items": {
|
|
84
|
+
"type": "string"
|
|
85
|
+
}
|
|
72
86
|
}
|
|
73
87
|
},
|
|
74
88
|
"additionalProperties": false
|