@unispechq/unispec-core 0.2.0 → 0.2.3
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 +223 -223
- package/dist/cjs/converters/index.js +197 -0
- package/dist/cjs/diff/index.js +236 -0
- package/dist/cjs/index.js +22 -0
- package/dist/cjs/loader/index.js +22 -0
- package/dist/cjs/normalizer/index.js +107 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/types/index.js +3 -0
- package/dist/cjs/validator/index.js +92 -0
- package/dist/converters/index.js +13 -22
- package/dist/diff/index.js +22 -22
- package/dist/index.cjs +22 -0
- package/dist/normalizer/index.js +6 -6
- package/dist/types/index.d.ts +1 -10
- package/dist/validator/index.js +22 -9
- package/package.json +32 -3
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.diffUniSpec = diffUniSpec;
|
|
4
|
+
function isPlainObject(value) {
|
|
5
|
+
return Object.prototype.toString.call(value) === "[object Object]";
|
|
6
|
+
}
|
|
7
|
+
function diffValues(oldVal, newVal, basePath, out) {
|
|
8
|
+
if (oldVal === newVal) {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
// Both plain objects → recurse by keys
|
|
12
|
+
if (isPlainObject(oldVal) && isPlainObject(newVal)) {
|
|
13
|
+
const oldKeys = new Set(Object.keys(oldVal));
|
|
14
|
+
const newKeys = new Set(Object.keys(newVal));
|
|
15
|
+
// Removed keys
|
|
16
|
+
for (const key of oldKeys) {
|
|
17
|
+
if (!newKeys.has(key)) {
|
|
18
|
+
out.push({
|
|
19
|
+
path: `${basePath}/${key}`,
|
|
20
|
+
description: "Field removed",
|
|
21
|
+
severity: "unknown",
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// Added / changed keys
|
|
26
|
+
for (const key of newKeys) {
|
|
27
|
+
const childPath = `${basePath}/${key}`;
|
|
28
|
+
if (!oldKeys.has(key)) {
|
|
29
|
+
out.push({
|
|
30
|
+
path: childPath,
|
|
31
|
+
description: "Field added",
|
|
32
|
+
severity: "unknown",
|
|
33
|
+
});
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
diffValues(oldVal[key], newVal[key], childPath, out);
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
// Arrays
|
|
41
|
+
if (Array.isArray(oldVal) && Array.isArray(newVal)) {
|
|
42
|
+
// Special handling for UniSpec collections identified by "name"
|
|
43
|
+
const isNamedCollection = basePath === "/protocols/rest/routes" ||
|
|
44
|
+
basePath === "/protocols/websocket/channels" ||
|
|
45
|
+
basePath === "/protocols/graphql/queries" ||
|
|
46
|
+
basePath === "/protocols/graphql/mutations" ||
|
|
47
|
+
basePath === "/protocols/graphql/subscriptions";
|
|
48
|
+
if (isNamedCollection) {
|
|
49
|
+
const oldByName = new Map();
|
|
50
|
+
const newByName = new Map();
|
|
51
|
+
for (const item of oldVal) {
|
|
52
|
+
if (item && typeof item === "object" && typeof item.name === "string") {
|
|
53
|
+
oldByName.set(item.name, item);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const item of newVal) {
|
|
57
|
+
if (item && typeof item === "object" && typeof item.name === "string") {
|
|
58
|
+
newByName.set(item.name, item);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Removed
|
|
62
|
+
for (const [name, oldItem] of oldByName.entries()) {
|
|
63
|
+
if (!newByName.has(name)) {
|
|
64
|
+
out.push({
|
|
65
|
+
path: `${basePath}/${name}`,
|
|
66
|
+
description: "Item removed",
|
|
67
|
+
severity: "unknown",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const newItem = newByName.get(name);
|
|
72
|
+
diffValues(oldItem, newItem, `${basePath}/${name}`, out);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Added
|
|
76
|
+
for (const [name] of newByName.entries()) {
|
|
77
|
+
if (!oldByName.has(name)) {
|
|
78
|
+
out.push({
|
|
79
|
+
path: `${basePath}/${name}`,
|
|
80
|
+
description: "Item added",
|
|
81
|
+
severity: "unknown",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// Generic shallow index-based compare
|
|
88
|
+
const maxLen = Math.max(oldVal.length, newVal.length);
|
|
89
|
+
for (let i = 0; i < maxLen; i++) {
|
|
90
|
+
const childPath = `${basePath}/${i}`;
|
|
91
|
+
if (i >= oldVal.length) {
|
|
92
|
+
out.push({
|
|
93
|
+
path: childPath,
|
|
94
|
+
description: "Item added",
|
|
95
|
+
severity: "unknown",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
else if (i >= newVal.length) {
|
|
99
|
+
out.push({
|
|
100
|
+
path: childPath,
|
|
101
|
+
description: "Item removed",
|
|
102
|
+
severity: "unknown",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
diffValues(oldVal[i], newVal[i], childPath, out);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// Primitive or mismatched types → treat as value change
|
|
112
|
+
out.push({
|
|
113
|
+
path: basePath,
|
|
114
|
+
description: "Value changed",
|
|
115
|
+
severity: "unknown",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
function annotateRestChange(change) {
|
|
119
|
+
if (!change.path.startsWith("/protocols/rest/routes/")) {
|
|
120
|
+
return change;
|
|
121
|
+
}
|
|
122
|
+
const segments = change.path.split("/").filter(Boolean);
|
|
123
|
+
// Expected shape: ["protocols", "rest", "routes", index]
|
|
124
|
+
if (segments[0] !== "protocols" || segments[1] !== "rest" || segments[2] !== "routes") {
|
|
125
|
+
return change;
|
|
126
|
+
}
|
|
127
|
+
const index = segments[3];
|
|
128
|
+
if (typeof index === "undefined") {
|
|
129
|
+
return change;
|
|
130
|
+
}
|
|
131
|
+
const annotated = {
|
|
132
|
+
...change,
|
|
133
|
+
protocol: "rest",
|
|
134
|
+
};
|
|
135
|
+
if (change.description === "Item removed" || change.description === "Field removed") {
|
|
136
|
+
annotated.kind = "rest.route.removed";
|
|
137
|
+
annotated.severity = "breaking";
|
|
138
|
+
}
|
|
139
|
+
else if (change.description === "Item added" || change.description === "Field added") {
|
|
140
|
+
annotated.kind = "rest.route.added";
|
|
141
|
+
annotated.severity = "non-breaking";
|
|
142
|
+
}
|
|
143
|
+
return annotated;
|
|
144
|
+
}
|
|
145
|
+
function annotateWebSocketChange(change) {
|
|
146
|
+
if (!change.path.startsWith("/protocols/websocket/channels/")) {
|
|
147
|
+
return change;
|
|
148
|
+
}
|
|
149
|
+
const segments = change.path.split("/").filter(Boolean);
|
|
150
|
+
// Expected base: ["protocols","websocket","channels", channelIndex, ...]
|
|
151
|
+
if (segments[0] !== "protocols" || segments[1] !== "websocket" || segments[2] !== "channels") {
|
|
152
|
+
return change;
|
|
153
|
+
}
|
|
154
|
+
const channelIndex = segments[3];
|
|
155
|
+
const next = segments[4];
|
|
156
|
+
const annotated = {
|
|
157
|
+
...change,
|
|
158
|
+
protocol: "websocket",
|
|
159
|
+
};
|
|
160
|
+
if (typeof channelIndex === "undefined") {
|
|
161
|
+
return annotated;
|
|
162
|
+
}
|
|
163
|
+
// Channel-level changes: /protocols/websocket/channels/{index}
|
|
164
|
+
if (!next) {
|
|
165
|
+
if (change.description === "Item removed" || change.description === "Field removed") {
|
|
166
|
+
annotated.kind = "websocket.channel.removed";
|
|
167
|
+
annotated.severity = "breaking";
|
|
168
|
+
}
|
|
169
|
+
else if (change.description === "Item added" || change.description === "Field added") {
|
|
170
|
+
annotated.kind = "websocket.channel.added";
|
|
171
|
+
annotated.severity = "non-breaking";
|
|
172
|
+
}
|
|
173
|
+
return annotated;
|
|
174
|
+
}
|
|
175
|
+
// Message-level changes: /protocols/websocket/channels/{index}/messages/{msgIndex}
|
|
176
|
+
if (next === "messages") {
|
|
177
|
+
const messageIndex = segments[5];
|
|
178
|
+
if (typeof messageIndex === "undefined") {
|
|
179
|
+
return annotated;
|
|
180
|
+
}
|
|
181
|
+
if (change.description === "Item removed") {
|
|
182
|
+
annotated.kind = "websocket.message.removed";
|
|
183
|
+
annotated.severity = "breaking";
|
|
184
|
+
}
|
|
185
|
+
else if (change.description === "Item added") {
|
|
186
|
+
annotated.kind = "websocket.message.added";
|
|
187
|
+
annotated.severity = "non-breaking";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return annotated;
|
|
191
|
+
}
|
|
192
|
+
function annotateGraphQLChange(change) {
|
|
193
|
+
if (!change.path.startsWith("/protocols/graphql/")) {
|
|
194
|
+
return change;
|
|
195
|
+
}
|
|
196
|
+
const segments = change.path.split("/").filter(Boolean);
|
|
197
|
+
// Expected: ["protocols","graphql", kind, index, ...]
|
|
198
|
+
if (segments[0] !== "protocols" || segments[1] !== "graphql") {
|
|
199
|
+
return change;
|
|
200
|
+
}
|
|
201
|
+
const opKind = segments[2];
|
|
202
|
+
const index = segments[3];
|
|
203
|
+
if (!opKind || typeof index === "undefined") {
|
|
204
|
+
return change;
|
|
205
|
+
}
|
|
206
|
+
if (opKind !== "queries" && opKind !== "mutations" && opKind !== "subscriptions") {
|
|
207
|
+
return change;
|
|
208
|
+
}
|
|
209
|
+
const annotated = {
|
|
210
|
+
...change,
|
|
211
|
+
protocol: "graphql",
|
|
212
|
+
};
|
|
213
|
+
if (change.description === "Item removed" || change.description === "Field removed") {
|
|
214
|
+
annotated.kind = "graphql.operation.removed";
|
|
215
|
+
annotated.severity = "breaking";
|
|
216
|
+
}
|
|
217
|
+
else if (change.description === "Item added" || change.description === "Field added") {
|
|
218
|
+
annotated.kind = "graphql.operation.added";
|
|
219
|
+
annotated.severity = "non-breaking";
|
|
220
|
+
}
|
|
221
|
+
return annotated;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Compute a structural diff between two UniSpec documents.
|
|
225
|
+
*
|
|
226
|
+
* Current behavior:
|
|
227
|
+
* - Tracks added, removed, and changed fields and array items.
|
|
228
|
+
* - Uses JSON Pointer-like paths rooted at "" (e.g., "/info/title").
|
|
229
|
+
* - Marks all changes with severity "unknown" for now.
|
|
230
|
+
*/
|
|
231
|
+
function diffUniSpec(oldDoc, newDoc) {
|
|
232
|
+
const changes = [];
|
|
233
|
+
diffValues(oldDoc, newDoc, "", changes);
|
|
234
|
+
const annotated = changes.map((change) => annotateWebSocketChange(annotateGraphQLChange(annotateRestChange(change))));
|
|
235
|
+
return { changes: annotated };
|
|
236
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./types/index.js"), exports);
|
|
18
|
+
__exportStar(require("./loader/index.js"), exports);
|
|
19
|
+
__exportStar(require("./validator/index.js"), exports);
|
|
20
|
+
__exportStar(require("./normalizer/index.js"), exports);
|
|
21
|
+
__exportStar(require("./diff/index.js"), exports);
|
|
22
|
+
__exportStar(require("./converters/index.js"), exports);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.loadUniSpec = loadUniSpec;
|
|
4
|
+
/**
|
|
5
|
+
* Load a UniSpec document from a raw input value.
|
|
6
|
+
* Currently supports:
|
|
7
|
+
* - JavaScript objects (treated as already parsed UniSpec)
|
|
8
|
+
* - JSON strings
|
|
9
|
+
*
|
|
10
|
+
* YAML and filesystem helpers will be added later, keeping this API stable.
|
|
11
|
+
*/
|
|
12
|
+
async function loadUniSpec(input, _options = {}) {
|
|
13
|
+
if (typeof input === "string") {
|
|
14
|
+
const trimmed = input.trim();
|
|
15
|
+
if (!trimmed) {
|
|
16
|
+
throw new Error("Cannot load UniSpec: input string is empty");
|
|
17
|
+
}
|
|
18
|
+
// For now we assume JSON; YAML support will be added later.
|
|
19
|
+
return JSON.parse(trimmed);
|
|
20
|
+
}
|
|
21
|
+
return input;
|
|
22
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalizeUniSpec = normalizeUniSpec;
|
|
4
|
+
function isPlainObject(value) {
|
|
5
|
+
return Object.prototype.toString.call(value) === "[object Object]";
|
|
6
|
+
}
|
|
7
|
+
function normalizeValue(value) {
|
|
8
|
+
if (Array.isArray(value)) {
|
|
9
|
+
return value.map((item) => normalizeValue(item));
|
|
10
|
+
}
|
|
11
|
+
if (isPlainObject(value)) {
|
|
12
|
+
const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
|
|
13
|
+
const normalized = {};
|
|
14
|
+
for (const [key, val] of entries) {
|
|
15
|
+
normalized[key] = normalizeValue(val);
|
|
16
|
+
}
|
|
17
|
+
return normalized;
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
function normalizeRestRoutes(doc) {
|
|
22
|
+
if (!doc || !doc.protocols) {
|
|
23
|
+
return doc;
|
|
24
|
+
}
|
|
25
|
+
const protocols = doc.protocols;
|
|
26
|
+
const rest = protocols.rest;
|
|
27
|
+
if (!rest || !Array.isArray(rest.routes)) {
|
|
28
|
+
return doc;
|
|
29
|
+
}
|
|
30
|
+
const routes = [...rest.routes];
|
|
31
|
+
routes.sort((a, b) => {
|
|
32
|
+
const keyA = a.name || `${a.path} ${a.method}`;
|
|
33
|
+
const keyB = b.name || `${b.path} ${b.method}`;
|
|
34
|
+
return keyA.localeCompare(keyB);
|
|
35
|
+
});
|
|
36
|
+
rest.routes = routes;
|
|
37
|
+
return doc;
|
|
38
|
+
}
|
|
39
|
+
function normalizeWebSocket(doc) {
|
|
40
|
+
if (!doc || !doc.protocols) {
|
|
41
|
+
return doc;
|
|
42
|
+
}
|
|
43
|
+
const protocols = doc.protocols;
|
|
44
|
+
const websocket = protocols.websocket;
|
|
45
|
+
if (!websocket || !Array.isArray(websocket.channels)) {
|
|
46
|
+
return doc;
|
|
47
|
+
}
|
|
48
|
+
const channels = websocket.channels.map((channel) => {
|
|
49
|
+
if (!channel || !Array.isArray(channel.messages)) {
|
|
50
|
+
return channel;
|
|
51
|
+
}
|
|
52
|
+
const sortedMessages = [...channel.messages].sort((a, b) => {
|
|
53
|
+
const aName = a?.name ?? "";
|
|
54
|
+
const bName = b?.name ?? "";
|
|
55
|
+
return aName.localeCompare(bName);
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
...channel,
|
|
59
|
+
messages: sortedMessages,
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
channels.sort((a, b) => {
|
|
63
|
+
const aName = a?.name ?? "";
|
|
64
|
+
const bName = b?.name ?? "";
|
|
65
|
+
return aName.localeCompare(bName);
|
|
66
|
+
});
|
|
67
|
+
websocket.channels = channels;
|
|
68
|
+
return doc;
|
|
69
|
+
}
|
|
70
|
+
function normalizeGraphqlOperations(doc) {
|
|
71
|
+
if (!doc || !doc.protocols) {
|
|
72
|
+
return doc;
|
|
73
|
+
}
|
|
74
|
+
const protocols = doc.protocols;
|
|
75
|
+
const graphql = protocols.graphql;
|
|
76
|
+
if (!graphql) {
|
|
77
|
+
return doc;
|
|
78
|
+
}
|
|
79
|
+
const kinds = [
|
|
80
|
+
"queries",
|
|
81
|
+
"mutations",
|
|
82
|
+
"subscriptions",
|
|
83
|
+
];
|
|
84
|
+
for (const kind of kinds) {
|
|
85
|
+
const ops = graphql[kind];
|
|
86
|
+
if (!Array.isArray(ops)) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
graphql[kind] = [...ops].sort((a, b) => {
|
|
90
|
+
const aName = a?.name ?? "";
|
|
91
|
+
const bName = b?.name ?? "";
|
|
92
|
+
return aName.localeCompare(bName);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return doc;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Normalize a UniSpec document into a canonical, deterministic form.
|
|
99
|
+
*
|
|
100
|
+
* Current behavior:
|
|
101
|
+
* - Recursively sorts object keys lexicographically.
|
|
102
|
+
* - Preserves values as-is.
|
|
103
|
+
*/
|
|
104
|
+
function normalizeUniSpec(doc, _options = {}) {
|
|
105
|
+
const normalized = normalizeValue(doc);
|
|
106
|
+
return normalizeWebSocket(normalizeGraphqlOperations(normalizeRestRoutes(normalized)));
|
|
107
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.validateUniSpec = validateUniSpec;
|
|
7
|
+
exports.validateUniSpecTests = validateUniSpecTests;
|
|
8
|
+
const _2020_js_1 = __importDefault(require("ajv/dist/2020.js"));
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
const unispec_schema_1 = require("@unispechq/unispec-schema");
|
|
12
|
+
const ajv = new _2020_js_1.default({
|
|
13
|
+
allErrors: true,
|
|
14
|
+
strict: true,
|
|
15
|
+
});
|
|
16
|
+
// Register minimal URI format to satisfy UniSpec schemas (service.environments[*].baseUrl)
|
|
17
|
+
ajv.addFormat("uri", true);
|
|
18
|
+
// Register all UniSpec subschemas so that Ajv can resolve internal $ref links
|
|
19
|
+
try {
|
|
20
|
+
const schemaDir = path_1.default.join(process.cwd(), "node_modules", "@unispechq", "unispec-schema", "schema");
|
|
21
|
+
const types = unispec_schema_1.manifest?.types ?? {};
|
|
22
|
+
const typeSchemaPaths = Object.values(types).map((rel) => String(rel));
|
|
23
|
+
const loadedTypeSchemas = typeSchemaPaths
|
|
24
|
+
.map((relPath) => path_1.default.join(schemaDir, relPath))
|
|
25
|
+
.filter((filePath) => fs_1.default.existsSync(filePath))
|
|
26
|
+
.map((filePath) => JSON.parse(fs_1.default.readFileSync(filePath, "utf8")));
|
|
27
|
+
ajv.addSchema(loadedTypeSchemas);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// If subschemas cannot be loaded for some reason, validation will still work for
|
|
31
|
+
// parts of the schema that do not rely on those $ref references.
|
|
32
|
+
}
|
|
33
|
+
const validateFn = ajv.compile(unispec_schema_1.unispec);
|
|
34
|
+
let validateTestsFn;
|
|
35
|
+
function mapAjvErrors(errors) {
|
|
36
|
+
if (!errors)
|
|
37
|
+
return [];
|
|
38
|
+
return errors.map((error) => ({
|
|
39
|
+
message: error.message || "UniSpec validation error",
|
|
40
|
+
path: error.instancePath || error.schemaPath,
|
|
41
|
+
code: error.keyword,
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Validate a UniSpec document against the UniSpec JSON Schema.
|
|
46
|
+
*/
|
|
47
|
+
async function validateUniSpec(doc, _options = {}) {
|
|
48
|
+
const docForValidation = {
|
|
49
|
+
unispecVersion: "0.0.0",
|
|
50
|
+
service: {
|
|
51
|
+
name: doc.name,
|
|
52
|
+
description: doc.description,
|
|
53
|
+
version: doc.version,
|
|
54
|
+
protocols: doc.protocols,
|
|
55
|
+
schemas: doc.schemas,
|
|
56
|
+
},
|
|
57
|
+
extensions: doc.extensions,
|
|
58
|
+
};
|
|
59
|
+
const valid = validateFn(docForValidation);
|
|
60
|
+
if (valid) {
|
|
61
|
+
return {
|
|
62
|
+
valid: true,
|
|
63
|
+
errors: [],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
valid: false,
|
|
68
|
+
errors: mapAjvErrors(validateFn.errors),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Validate a UniSpec Tests document against the UniSpec Tests JSON Schema.
|
|
73
|
+
*/
|
|
74
|
+
async function validateUniSpecTests(doc, _options = {}) {
|
|
75
|
+
if (!validateTestsFn) {
|
|
76
|
+
const schemaDir = path_1.default.join(process.cwd(), "node_modules", "@unispechq", "unispec-schema", "schema");
|
|
77
|
+
const testsSchemaPath = path_1.default.join(schemaDir, "unispec-tests.schema.json");
|
|
78
|
+
const testsSchema = JSON.parse(fs_1.default.readFileSync(testsSchemaPath, "utf8"));
|
|
79
|
+
validateTestsFn = ajv.compile(testsSchema);
|
|
80
|
+
}
|
|
81
|
+
const valid = validateTestsFn(doc);
|
|
82
|
+
if (valid) {
|
|
83
|
+
return {
|
|
84
|
+
valid: true,
|
|
85
|
+
errors: [],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
valid: false,
|
|
90
|
+
errors: mapAjvErrors(validateTestsFn.errors),
|
|
91
|
+
};
|
|
92
|
+
}
|
package/dist/converters/index.js
CHANGED
|
@@ -1,21 +1,14 @@
|
|
|
1
1
|
export function toOpenAPI(doc) {
|
|
2
|
-
const
|
|
3
|
-
const rest = (service.protocols?.rest ?? {});
|
|
2
|
+
const rest = (doc.protocols?.rest ?? {});
|
|
4
3
|
const info = {
|
|
5
|
-
title:
|
|
6
|
-
description:
|
|
4
|
+
title: doc.name,
|
|
5
|
+
description: doc.description,
|
|
7
6
|
};
|
|
8
|
-
|
|
9
|
-
const servers = Array.isArray(service.environments)
|
|
10
|
-
? service.environments.map((env) => ({
|
|
11
|
-
url: env.baseUrl,
|
|
12
|
-
description: env.name,
|
|
13
|
-
}))
|
|
14
|
-
: [];
|
|
7
|
+
const servers = [];
|
|
15
8
|
const paths = {};
|
|
16
9
|
const components = {};
|
|
17
|
-
// Map
|
|
18
|
-
const schemas = (
|
|
10
|
+
// Map doc.schemas into OpenAPI components.schemas
|
|
11
|
+
const schemas = (doc.schemas ?? {});
|
|
19
12
|
const componentsSchemas = {};
|
|
20
13
|
for (const [name, def] of Object.entries(schemas)) {
|
|
21
14
|
componentsSchemas[name] = def.jsonSchema;
|
|
@@ -140,14 +133,13 @@ export function toGraphQLSDL(doc) {
|
|
|
140
133
|
// via a Query field. This does not attempt to interpret the full GraphQL
|
|
141
134
|
// protocol structure yet, but provides a stable, deterministic SDL shape
|
|
142
135
|
// based on top-level UniSpec document fields.
|
|
143
|
-
const graphql = doc.
|
|
136
|
+
const graphql = doc.protocols?.graphql;
|
|
144
137
|
const customSDL = graphql?.schema;
|
|
145
138
|
if (typeof customSDL === "string" && customSDL.trim()) {
|
|
146
139
|
return { sdl: customSDL };
|
|
147
140
|
}
|
|
148
|
-
const
|
|
149
|
-
const
|
|
150
|
-
const description = service.description ?? "";
|
|
141
|
+
const title = doc.name;
|
|
142
|
+
const description = doc.description ?? "";
|
|
151
143
|
const lines = [];
|
|
152
144
|
if (title || description) {
|
|
153
145
|
lines.push("\"\"");
|
|
@@ -175,8 +167,7 @@ export function toWebSocketModel(doc) {
|
|
|
175
167
|
// It exposes service metadata, a normalized list of channels and the raw
|
|
176
168
|
// websocket protocol object, while also embedding the original UniSpec
|
|
177
169
|
// document under a technical key for debugging and introspection.
|
|
178
|
-
const
|
|
179
|
-
const websocket = (service.protocols?.websocket ?? {});
|
|
170
|
+
const websocket = (doc.protocols?.websocket ?? {});
|
|
180
171
|
const channelsArray = Array.isArray(websocket.channels) ? websocket.channels : [];
|
|
181
172
|
const channels = [...channelsArray]
|
|
182
173
|
.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""))
|
|
@@ -190,9 +181,9 @@ export function toWebSocketModel(doc) {
|
|
|
190
181
|
}));
|
|
191
182
|
return {
|
|
192
183
|
service: {
|
|
193
|
-
name:
|
|
194
|
-
title:
|
|
195
|
-
description:
|
|
184
|
+
name: doc.name,
|
|
185
|
+
title: undefined,
|
|
186
|
+
description: doc.description,
|
|
196
187
|
},
|
|
197
188
|
channels,
|
|
198
189
|
rawProtocol: websocket,
|
package/dist/diff/index.js
CHANGED
|
@@ -37,11 +37,11 @@ function diffValues(oldVal, newVal, basePath, out) {
|
|
|
37
37
|
// Arrays
|
|
38
38
|
if (Array.isArray(oldVal) && Array.isArray(newVal)) {
|
|
39
39
|
// Special handling for UniSpec collections identified by "name"
|
|
40
|
-
const isNamedCollection = basePath === "/
|
|
41
|
-
basePath === "/
|
|
42
|
-
basePath === "/
|
|
43
|
-
basePath === "/
|
|
44
|
-
basePath === "/
|
|
40
|
+
const isNamedCollection = basePath === "/protocols/rest/routes" ||
|
|
41
|
+
basePath === "/protocols/websocket/channels" ||
|
|
42
|
+
basePath === "/protocols/graphql/queries" ||
|
|
43
|
+
basePath === "/protocols/graphql/mutations" ||
|
|
44
|
+
basePath === "/protocols/graphql/subscriptions";
|
|
45
45
|
if (isNamedCollection) {
|
|
46
46
|
const oldByName = new Map();
|
|
47
47
|
const newByName = new Map();
|
|
@@ -113,15 +113,15 @@ function diffValues(oldVal, newVal, basePath, out) {
|
|
|
113
113
|
});
|
|
114
114
|
}
|
|
115
115
|
function annotateRestChange(change) {
|
|
116
|
-
if (!change.path.startsWith("/
|
|
116
|
+
if (!change.path.startsWith("/protocols/rest/routes/")) {
|
|
117
117
|
return change;
|
|
118
118
|
}
|
|
119
119
|
const segments = change.path.split("/").filter(Boolean);
|
|
120
|
-
// Expected shape: ["
|
|
121
|
-
if (segments[0] !== "
|
|
120
|
+
// Expected shape: ["protocols", "rest", "routes", index]
|
|
121
|
+
if (segments[0] !== "protocols" || segments[1] !== "rest" || segments[2] !== "routes") {
|
|
122
122
|
return change;
|
|
123
123
|
}
|
|
124
|
-
const index = segments[
|
|
124
|
+
const index = segments[3];
|
|
125
125
|
if (typeof index === "undefined") {
|
|
126
126
|
return change;
|
|
127
127
|
}
|
|
@@ -140,16 +140,16 @@ function annotateRestChange(change) {
|
|
|
140
140
|
return annotated;
|
|
141
141
|
}
|
|
142
142
|
function annotateWebSocketChange(change) {
|
|
143
|
-
if (!change.path.startsWith("/
|
|
143
|
+
if (!change.path.startsWith("/protocols/websocket/channels/")) {
|
|
144
144
|
return change;
|
|
145
145
|
}
|
|
146
146
|
const segments = change.path.split("/").filter(Boolean);
|
|
147
|
-
// Expected base: ["
|
|
148
|
-
if (segments[0] !== "
|
|
147
|
+
// Expected base: ["protocols","websocket","channels", channelIndex, ...]
|
|
148
|
+
if (segments[0] !== "protocols" || segments[1] !== "websocket" || segments[2] !== "channels") {
|
|
149
149
|
return change;
|
|
150
150
|
}
|
|
151
|
-
const channelIndex = segments[
|
|
152
|
-
const next = segments[
|
|
151
|
+
const channelIndex = segments[3];
|
|
152
|
+
const next = segments[4];
|
|
153
153
|
const annotated = {
|
|
154
154
|
...change,
|
|
155
155
|
protocol: "websocket",
|
|
@@ -157,7 +157,7 @@ function annotateWebSocketChange(change) {
|
|
|
157
157
|
if (typeof channelIndex === "undefined") {
|
|
158
158
|
return annotated;
|
|
159
159
|
}
|
|
160
|
-
// Channel-level changes: /
|
|
160
|
+
// Channel-level changes: /protocols/websocket/channels/{index}
|
|
161
161
|
if (!next) {
|
|
162
162
|
if (change.description === "Item removed" || change.description === "Field removed") {
|
|
163
163
|
annotated.kind = "websocket.channel.removed";
|
|
@@ -169,9 +169,9 @@ function annotateWebSocketChange(change) {
|
|
|
169
169
|
}
|
|
170
170
|
return annotated;
|
|
171
171
|
}
|
|
172
|
-
// Message-level changes: /
|
|
172
|
+
// Message-level changes: /protocols/websocket/channels/{index}/messages/{msgIndex}
|
|
173
173
|
if (next === "messages") {
|
|
174
|
-
const messageIndex = segments[
|
|
174
|
+
const messageIndex = segments[5];
|
|
175
175
|
if (typeof messageIndex === "undefined") {
|
|
176
176
|
return annotated;
|
|
177
177
|
}
|
|
@@ -187,16 +187,16 @@ function annotateWebSocketChange(change) {
|
|
|
187
187
|
return annotated;
|
|
188
188
|
}
|
|
189
189
|
function annotateGraphQLChange(change) {
|
|
190
|
-
if (!change.path.startsWith("/
|
|
190
|
+
if (!change.path.startsWith("/protocols/graphql/")) {
|
|
191
191
|
return change;
|
|
192
192
|
}
|
|
193
193
|
const segments = change.path.split("/").filter(Boolean);
|
|
194
|
-
// Expected: ["
|
|
195
|
-
if (segments[0] !== "
|
|
194
|
+
// Expected: ["protocols","graphql", kind, index, ...]
|
|
195
|
+
if (segments[0] !== "protocols" || segments[1] !== "graphql") {
|
|
196
196
|
return change;
|
|
197
197
|
}
|
|
198
|
-
const opKind = segments[
|
|
199
|
-
const index = segments[
|
|
198
|
+
const opKind = segments[2];
|
|
199
|
+
const index = segments[3];
|
|
200
200
|
if (!opKind || typeof index === "undefined") {
|
|
201
201
|
return change;
|
|
202
202
|
}
|