@unispechq/unispec-core 0.1.2 → 0.2.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/README.md +7 -7
- package/dist/converters/index.js +135 -23
- package/dist/diff/index.js +80 -42
- package/dist/normalizer/index.js +41 -53
- package/dist/types/index.d.ts +161 -20
- package/dist/types/index.js +1 -0
- package/dist/validator/index.d.ts +5 -1
- package/dist/validator/index.js +25 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -59,7 +59,7 @@ Core Engine provides:
|
|
|
59
59
|
|
|
60
60
|
- YAML/JSON loader
|
|
61
61
|
- JSON Schema validator
|
|
62
|
-
– Normalizer → canonical UniSpec output (REST
|
|
62
|
+
– Normalizer → canonical UniSpec output (REST routes, GraphQL operations, WebSocket channels/messages)
|
|
63
63
|
– Diff engine (with basic breaking / non-breaking classification for REST, GraphQL and WebSocket)
|
|
64
64
|
– Converters:
|
|
65
65
|
- UniSpec → OpenAPI (REST-centric)
|
|
@@ -77,19 +77,19 @@ This is the foundation used by:
|
|
|
77
77
|
At the level of the Core Engine, protocol support is intentionally minimal but deterministic:
|
|
78
78
|
|
|
79
79
|
- **REST**
|
|
80
|
-
-
|
|
81
|
-
- Normalizer orders `
|
|
82
|
-
- Diff engine annotates
|
|
83
|
-
- Converter exposes REST as an OpenAPI 3.1 document while preserving the original UniSpec under `x-unispec`.
|
|
80
|
+
- Typed REST surface defined by `service.protocols.rest.routes[]` and reusable `service.schemas`.
|
|
81
|
+
- Normalizer orders routes by `name` (or `path + method`) for stable diffs.
|
|
82
|
+
- Diff engine annotates route additions and removals with breaking/non-breaking severity.
|
|
83
|
+
- Converter exposes REST as an OpenAPI 3.1 document built from routes, schemas and environments, while preserving the original UniSpec under `x-unispec`.
|
|
84
84
|
|
|
85
85
|
- **GraphQL**
|
|
86
|
-
- Typed protocol with `schema` (SDL) and `
|
|
86
|
+
- Typed protocol with `schema` (SDL string) and `queries`/`mutations`/`subscriptions` as operation lists.
|
|
87
87
|
- Normalizer orders operation names within each bucket.
|
|
88
88
|
- Diff engine annotates operation additions/removals as non-breaking/breaking.
|
|
89
89
|
- Converter either passes through user-provided SDL or generates a minimal, deterministic SDL shell.
|
|
90
90
|
|
|
91
91
|
- **WebSocket**
|
|
92
|
-
- Typed protocol with channels and messages
|
|
92
|
+
- Typed protocol with channels and messages backed by reusable schemas and security schemes.
|
|
93
93
|
- Normalizer orders channels by name and messages by `name` within each channel.
|
|
94
94
|
- Diff engine annotates channel/message additions/removals as non-breaking/breaking changes.
|
|
95
95
|
- Converter produces a dashboard-friendly model with service metadata, a normalized channel list and the raw protocol.
|
package/dist/converters/index.js
CHANGED
|
@@ -1,22 +1,137 @@
|
|
|
1
1
|
export function toOpenAPI(doc) {
|
|
2
2
|
const service = doc.service;
|
|
3
|
-
const rest = service.protocols?.rest;
|
|
3
|
+
const rest = (service.protocols?.rest ?? {});
|
|
4
4
|
const info = {
|
|
5
5
|
title: service.title ?? service.name,
|
|
6
6
|
description: service.description,
|
|
7
7
|
};
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
// Derive OpenAPI servers from UniSpec environments when available.
|
|
9
|
+
const servers = Array.isArray(service.environments)
|
|
10
|
+
? service.environments.map((env) => ({
|
|
11
|
+
url: env.baseUrl,
|
|
12
|
+
description: env.name,
|
|
13
|
+
}))
|
|
14
|
+
: [];
|
|
15
|
+
const paths = {};
|
|
16
|
+
const components = {};
|
|
17
|
+
// Map service.schemas into OpenAPI components.schemas
|
|
18
|
+
const schemas = (service.schemas ?? {});
|
|
19
|
+
const componentsSchemas = {};
|
|
20
|
+
for (const [name, def] of Object.entries(schemas)) {
|
|
21
|
+
componentsSchemas[name] = def.jsonSchema;
|
|
22
|
+
}
|
|
23
|
+
if (Object.keys(componentsSchemas).length > 0) {
|
|
24
|
+
components.schemas = componentsSchemas;
|
|
25
|
+
}
|
|
26
|
+
// Helper to build a $ref into components.schemas when schemaRef is present.
|
|
27
|
+
function schemaRefToOpenAPI(schemaRef) {
|
|
28
|
+
if (!schemaRef) {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
return { $ref: `#/components/schemas/${schemaRef}` };
|
|
32
|
+
}
|
|
33
|
+
// Build paths from REST routes.
|
|
34
|
+
if (Array.isArray(rest.routes)) {
|
|
35
|
+
for (const route of rest.routes) {
|
|
36
|
+
const pathItem = (paths[route.path] ?? {});
|
|
37
|
+
const method = route.method.toLowerCase();
|
|
38
|
+
const parameters = [];
|
|
39
|
+
// Path params
|
|
40
|
+
for (const param of route.pathParams ?? []) {
|
|
41
|
+
parameters.push({
|
|
42
|
+
name: param.name,
|
|
43
|
+
in: "path",
|
|
44
|
+
required: param.required ?? true,
|
|
45
|
+
description: param.description,
|
|
46
|
+
schema: schemaRefToOpenAPI(param.schemaRef),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
// Query params
|
|
50
|
+
for (const param of route.queryParams ?? []) {
|
|
51
|
+
parameters.push({
|
|
52
|
+
name: param.name,
|
|
53
|
+
in: "query",
|
|
54
|
+
required: param.required ?? false,
|
|
55
|
+
description: param.description,
|
|
56
|
+
schema: schemaRefToOpenAPI(param.schemaRef),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
// Header params
|
|
60
|
+
for (const param of route.headers ?? []) {
|
|
61
|
+
parameters.push({
|
|
62
|
+
name: param.name,
|
|
63
|
+
in: "header",
|
|
64
|
+
required: param.required ?? false,
|
|
65
|
+
description: param.description,
|
|
66
|
+
schema: schemaRefToOpenAPI(param.schemaRef),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// Request body
|
|
70
|
+
let requestBody;
|
|
71
|
+
if (route.requestBody && route.requestBody.content) {
|
|
72
|
+
const content = {};
|
|
73
|
+
for (const [mediaType, media] of Object.entries(route.requestBody.content)) {
|
|
74
|
+
content[mediaType] = {
|
|
75
|
+
schema: schemaRefToOpenAPI(media.schemaRef),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
requestBody = {
|
|
79
|
+
description: route.requestBody.description,
|
|
80
|
+
required: route.requestBody.required,
|
|
81
|
+
content,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// Responses
|
|
85
|
+
const responses = {};
|
|
86
|
+
for (const [status, resp] of Object.entries(route.responses ?? {})) {
|
|
87
|
+
const content = {};
|
|
88
|
+
if (resp.content) {
|
|
89
|
+
for (const [mediaType, media] of Object.entries(resp.content)) {
|
|
90
|
+
content[mediaType] = {
|
|
91
|
+
schema: schemaRefToOpenAPI(media.schemaRef),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
responses[status] = {
|
|
96
|
+
description: resp.description ?? "",
|
|
97
|
+
...(Object.keys(content).length > 0 ? { content } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const operation = {
|
|
101
|
+
operationId: route.name,
|
|
102
|
+
summary: route.summary,
|
|
103
|
+
description: route.description,
|
|
104
|
+
...(parameters.length > 0 ? { parameters } : {}),
|
|
105
|
+
responses: Object.keys(responses).length > 0 ? responses : { default: { description: "" } },
|
|
106
|
+
};
|
|
107
|
+
if (requestBody) {
|
|
108
|
+
operation.requestBody = requestBody;
|
|
109
|
+
}
|
|
110
|
+
// Security requirements
|
|
111
|
+
if (Array.isArray(route.security) && route.security.length > 0) {
|
|
112
|
+
operation.security = route.security.map((req) => {
|
|
113
|
+
const obj = {};
|
|
114
|
+
for (const schemeName of req) {
|
|
115
|
+
obj[schemeName] = [];
|
|
116
|
+
}
|
|
117
|
+
return obj;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
pathItem[method] = operation;
|
|
121
|
+
paths[route.path] = pathItem;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Security schemes pass-through from REST protocol
|
|
125
|
+
const componentsSecuritySchemes = rest.securitySchemes ?? {};
|
|
126
|
+
if (Object.keys(componentsSecuritySchemes).length > 0) {
|
|
127
|
+
components.securitySchemes = componentsSecuritySchemes;
|
|
128
|
+
}
|
|
14
129
|
return {
|
|
15
130
|
openapi: "3.1.0",
|
|
16
131
|
info,
|
|
17
132
|
servers,
|
|
18
133
|
paths,
|
|
19
|
-
...
|
|
134
|
+
...(Object.keys(components).length > 0 ? { components } : {}),
|
|
20
135
|
"x-unispec": doc,
|
|
21
136
|
};
|
|
22
137
|
}
|
|
@@ -26,7 +141,7 @@ export function toGraphQLSDL(doc) {
|
|
|
26
141
|
// protocol structure yet, but provides a stable, deterministic SDL shape
|
|
27
142
|
// based on top-level UniSpec document fields.
|
|
28
143
|
const graphql = doc.service.protocols?.graphql;
|
|
29
|
-
const customSDL = graphql?.schema
|
|
144
|
+
const customSDL = graphql?.schema;
|
|
30
145
|
if (typeof customSDL === "string" && customSDL.trim()) {
|
|
31
146
|
return { sdl: customSDL };
|
|
32
147
|
}
|
|
@@ -62,20 +177,17 @@ export function toWebSocketModel(doc) {
|
|
|
62
177
|
// document under a technical key for debugging and introspection.
|
|
63
178
|
const service = doc.service;
|
|
64
179
|
const websocket = (service.protocols?.websocket ?? {});
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
raw: channel,
|
|
77
|
-
};
|
|
78
|
-
});
|
|
180
|
+
const channelsArray = Array.isArray(websocket.channels) ? websocket.channels : [];
|
|
181
|
+
const channels = [...channelsArray]
|
|
182
|
+
.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""))
|
|
183
|
+
.map((channel) => ({
|
|
184
|
+
name: channel.name,
|
|
185
|
+
summary: undefined,
|
|
186
|
+
description: channel.description,
|
|
187
|
+
direction: channel.direction,
|
|
188
|
+
messages: channel.messages,
|
|
189
|
+
raw: channel,
|
|
190
|
+
}));
|
|
79
191
|
return {
|
|
80
192
|
service: {
|
|
81
193
|
name: service.name,
|
package/dist/diff/index.js
CHANGED
|
@@ -34,8 +34,54 @@ function diffValues(oldVal, newVal, basePath, out) {
|
|
|
34
34
|
}
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
37
|
-
// Arrays
|
|
37
|
+
// Arrays
|
|
38
38
|
if (Array.isArray(oldVal) && Array.isArray(newVal)) {
|
|
39
|
+
// Special handling for UniSpec collections identified by "name"
|
|
40
|
+
const isNamedCollection = basePath === "/service/protocols/rest/routes" ||
|
|
41
|
+
basePath === "/service/protocols/websocket/channels" ||
|
|
42
|
+
basePath === "/service/protocols/graphql/queries" ||
|
|
43
|
+
basePath === "/service/protocols/graphql/mutations" ||
|
|
44
|
+
basePath === "/service/protocols/graphql/subscriptions";
|
|
45
|
+
if (isNamedCollection) {
|
|
46
|
+
const oldByName = new Map();
|
|
47
|
+
const newByName = new Map();
|
|
48
|
+
for (const item of oldVal) {
|
|
49
|
+
if (item && typeof item === "object" && typeof item.name === "string") {
|
|
50
|
+
oldByName.set(item.name, item);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const item of newVal) {
|
|
54
|
+
if (item && typeof item === "object" && typeof item.name === "string") {
|
|
55
|
+
newByName.set(item.name, item);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Removed
|
|
59
|
+
for (const [name, oldItem] of oldByName.entries()) {
|
|
60
|
+
if (!newByName.has(name)) {
|
|
61
|
+
out.push({
|
|
62
|
+
path: `${basePath}/${name}`,
|
|
63
|
+
description: "Item removed",
|
|
64
|
+
severity: "unknown",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
const newItem = newByName.get(name);
|
|
69
|
+
diffValues(oldItem, newItem, `${basePath}/${name}`, out);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Added
|
|
73
|
+
for (const [name] of newByName.entries()) {
|
|
74
|
+
if (!oldByName.has(name)) {
|
|
75
|
+
out.push({
|
|
76
|
+
path: `${basePath}/${name}`,
|
|
77
|
+
description: "Item added",
|
|
78
|
+
severity: "unknown",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// Generic shallow index-based compare
|
|
39
85
|
const maxLen = Math.max(oldVal.length, newVal.length);
|
|
40
86
|
for (let i = 0; i < maxLen; i++) {
|
|
41
87
|
const childPath = `${basePath}/${i}`;
|
|
@@ -67,40 +113,29 @@ function diffValues(oldVal, newVal, basePath, out) {
|
|
|
67
113
|
});
|
|
68
114
|
}
|
|
69
115
|
function annotateRestChange(change) {
|
|
70
|
-
if (!change.path.startsWith("/service/protocols/rest/
|
|
116
|
+
if (!change.path.startsWith("/service/protocols/rest/routes/")) {
|
|
71
117
|
return change;
|
|
72
118
|
}
|
|
73
119
|
const segments = change.path.split("/").filter(Boolean);
|
|
74
|
-
// Expected shape: ["service", "protocols", "rest", "
|
|
75
|
-
if (segments[0] !== "service" || segments[1] !== "protocols" || segments[2] !== "rest" || segments[3] !== "
|
|
120
|
+
// Expected shape: ["service", "protocols", "rest", "routes", index]
|
|
121
|
+
if (segments[0] !== "service" || segments[1] !== "protocols" || segments[2] !== "rest" || segments[3] !== "routes") {
|
|
122
|
+
return change;
|
|
123
|
+
}
|
|
124
|
+
const index = segments[4];
|
|
125
|
+
if (typeof index === "undefined") {
|
|
76
126
|
return change;
|
|
77
127
|
}
|
|
78
|
-
const pathKey = segments[4];
|
|
79
|
-
const method = segments[5];
|
|
80
|
-
const httpMethods = new Set(["get", "head", "options", "post", "put", "patch", "delete"]);
|
|
81
128
|
const annotated = {
|
|
82
129
|
...change,
|
|
83
130
|
protocol: "rest",
|
|
84
131
|
};
|
|
85
|
-
if (change.description === "Field removed") {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
annotated.severity = "breaking";
|
|
89
|
-
}
|
|
90
|
-
else if (pathKey && method && httpMethods.has(method)) {
|
|
91
|
-
annotated.kind = "rest.operation.removed";
|
|
92
|
-
annotated.severity = "breaking";
|
|
93
|
-
}
|
|
132
|
+
if (change.description === "Item removed" || change.description === "Field removed") {
|
|
133
|
+
annotated.kind = "rest.route.removed";
|
|
134
|
+
annotated.severity = "breaking";
|
|
94
135
|
}
|
|
95
|
-
else if (change.description === "Field added") {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
annotated.severity = "non-breaking";
|
|
99
|
-
}
|
|
100
|
-
else if (pathKey && method && httpMethods.has(method)) {
|
|
101
|
-
annotated.kind = "rest.operation.added";
|
|
102
|
-
annotated.severity = "non-breaking";
|
|
103
|
-
}
|
|
136
|
+
else if (change.description === "Item added" || change.description === "Field added") {
|
|
137
|
+
annotated.kind = "rest.route.added";
|
|
138
|
+
annotated.severity = "non-breaking";
|
|
104
139
|
}
|
|
105
140
|
return annotated;
|
|
106
141
|
}
|
|
@@ -109,35 +144,35 @@ function annotateWebSocketChange(change) {
|
|
|
109
144
|
return change;
|
|
110
145
|
}
|
|
111
146
|
const segments = change.path.split("/").filter(Boolean);
|
|
112
|
-
// Expected: ["service","protocols","websocket","channels",
|
|
147
|
+
// Expected base: ["service","protocols","websocket","channels", channelIndex, ...]
|
|
113
148
|
if (segments[0] !== "service" || segments[1] !== "protocols" || segments[2] !== "websocket" || segments[3] !== "channels") {
|
|
114
149
|
return change;
|
|
115
150
|
}
|
|
116
|
-
const
|
|
151
|
+
const channelIndex = segments[4];
|
|
117
152
|
const next = segments[5];
|
|
118
153
|
const annotated = {
|
|
119
154
|
...change,
|
|
120
155
|
protocol: "websocket",
|
|
121
156
|
};
|
|
122
|
-
if (
|
|
157
|
+
if (typeof channelIndex === "undefined") {
|
|
123
158
|
return annotated;
|
|
124
159
|
}
|
|
125
|
-
// Channel-level changes
|
|
160
|
+
// Channel-level changes: /service/protocols/websocket/channels/{index}
|
|
126
161
|
if (!next) {
|
|
127
|
-
if (change.description === "Field removed") {
|
|
162
|
+
if (change.description === "Item removed" || change.description === "Field removed") {
|
|
128
163
|
annotated.kind = "websocket.channel.removed";
|
|
129
164
|
annotated.severity = "breaking";
|
|
130
165
|
}
|
|
131
|
-
else if (change.description === "Field added") {
|
|
166
|
+
else if (change.description === "Item added" || change.description === "Field added") {
|
|
132
167
|
annotated.kind = "websocket.channel.added";
|
|
133
168
|
annotated.severity = "non-breaking";
|
|
134
169
|
}
|
|
135
170
|
return annotated;
|
|
136
171
|
}
|
|
137
|
-
// Message-level changes
|
|
172
|
+
// Message-level changes: /service/protocols/websocket/channels/{index}/messages/{msgIndex}
|
|
138
173
|
if (next === "messages") {
|
|
139
|
-
const
|
|
140
|
-
if (typeof
|
|
174
|
+
const messageIndex = segments[6];
|
|
175
|
+
if (typeof messageIndex === "undefined") {
|
|
141
176
|
return annotated;
|
|
142
177
|
}
|
|
143
178
|
if (change.description === "Item removed") {
|
|
@@ -152,28 +187,31 @@ function annotateWebSocketChange(change) {
|
|
|
152
187
|
return annotated;
|
|
153
188
|
}
|
|
154
189
|
function annotateGraphQLChange(change) {
|
|
155
|
-
if (!change.path.startsWith("/service/protocols/graphql/
|
|
190
|
+
if (!change.path.startsWith("/service/protocols/graphql/")) {
|
|
156
191
|
return change;
|
|
157
192
|
}
|
|
158
193
|
const segments = change.path.split("/").filter(Boolean);
|
|
159
|
-
// Expected: ["service","protocols","graphql",
|
|
160
|
-
if (segments[0] !== "service" || segments[1] !== "protocols" || segments[2] !== "graphql"
|
|
194
|
+
// Expected: ["service","protocols","graphql", kind, index, ...]
|
|
195
|
+
if (segments[0] !== "service" || segments[1] !== "protocols" || segments[2] !== "graphql") {
|
|
196
|
+
return change;
|
|
197
|
+
}
|
|
198
|
+
const opKind = segments[3];
|
|
199
|
+
const index = segments[4];
|
|
200
|
+
if (!opKind || typeof index === "undefined") {
|
|
161
201
|
return change;
|
|
162
202
|
}
|
|
163
|
-
|
|
164
|
-
const opName = segments[5];
|
|
165
|
-
if (!opKind || !opName) {
|
|
203
|
+
if (opKind !== "queries" && opKind !== "mutations" && opKind !== "subscriptions") {
|
|
166
204
|
return change;
|
|
167
205
|
}
|
|
168
206
|
const annotated = {
|
|
169
207
|
...change,
|
|
170
208
|
protocol: "graphql",
|
|
171
209
|
};
|
|
172
|
-
if (change.description === "Field removed") {
|
|
210
|
+
if (change.description === "Item removed" || change.description === "Field removed") {
|
|
173
211
|
annotated.kind = "graphql.operation.removed";
|
|
174
212
|
annotated.severity = "breaking";
|
|
175
213
|
}
|
|
176
|
-
else if (change.description === "Field added") {
|
|
214
|
+
else if (change.description === "Item added" || change.description === "Field added") {
|
|
177
215
|
annotated.kind = "graphql.operation.added";
|
|
178
216
|
annotated.severity = "non-breaking";
|
|
179
217
|
}
|
package/dist/normalizer/index.js
CHANGED
|
@@ -15,37 +15,22 @@ function normalizeValue(value) {
|
|
|
15
15
|
}
|
|
16
16
|
return value;
|
|
17
17
|
}
|
|
18
|
-
function
|
|
18
|
+
function normalizeRestRoutes(doc) {
|
|
19
19
|
if (!doc || !doc.service || !doc.service.protocols) {
|
|
20
20
|
return doc;
|
|
21
21
|
}
|
|
22
22
|
const protocols = doc.service.protocols;
|
|
23
23
|
const rest = protocols.rest;
|
|
24
|
-
if (!rest || !
|
|
24
|
+
if (!rest || !Array.isArray(rest.routes)) {
|
|
25
25
|
return doc;
|
|
26
26
|
}
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
const pathItemObj = pathItem;
|
|
36
|
-
const ordered = {};
|
|
37
|
-
for (const method of httpMethodOrder) {
|
|
38
|
-
if (Object.prototype.hasOwnProperty.call(pathItemObj, method)) {
|
|
39
|
-
ordered[method] = pathItemObj[method];
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
const remainingKeys = Object.keys(pathItemObj).filter((k) => !httpMethodOrder.includes(k)).sort();
|
|
43
|
-
for (const key of remainingKeys) {
|
|
44
|
-
ordered[key] = pathItemObj[key];
|
|
45
|
-
}
|
|
46
|
-
normalizedPaths[path] = ordered;
|
|
47
|
-
}
|
|
48
|
-
rest.paths = normalizedPaths;
|
|
27
|
+
const routes = [...rest.routes];
|
|
28
|
+
routes.sort((a, b) => {
|
|
29
|
+
const keyA = a.name || `${a.path} ${a.method}`;
|
|
30
|
+
const keyB = b.name || `${b.path} ${b.method}`;
|
|
31
|
+
return keyA.localeCompare(keyB);
|
|
32
|
+
});
|
|
33
|
+
rest.routes = routes;
|
|
49
34
|
return doc;
|
|
50
35
|
}
|
|
51
36
|
function normalizeWebSocket(doc) {
|
|
@@ -54,30 +39,29 @@ function normalizeWebSocket(doc) {
|
|
|
54
39
|
}
|
|
55
40
|
const protocols = doc.service.protocols;
|
|
56
41
|
const websocket = protocols.websocket;
|
|
57
|
-
if (!websocket || !
|
|
42
|
+
if (!websocket || !Array.isArray(websocket.channels)) {
|
|
58
43
|
return doc;
|
|
59
44
|
}
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const channel = originalChannels[name];
|
|
64
|
-
if (!channel) {
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
let messages = channel.messages;
|
|
68
|
-
if (Array.isArray(messages)) {
|
|
69
|
-
messages = [...messages].sort((a, b) => {
|
|
70
|
-
const aName = a?.name ?? "";
|
|
71
|
-
const bName = b?.name ?? "";
|
|
72
|
-
return aName.localeCompare(bName);
|
|
73
|
-
});
|
|
45
|
+
const channels = websocket.channels.map((channel) => {
|
|
46
|
+
if (!channel || !Array.isArray(channel.messages)) {
|
|
47
|
+
return channel;
|
|
74
48
|
}
|
|
75
|
-
|
|
49
|
+
const sortedMessages = [...channel.messages].sort((a, b) => {
|
|
50
|
+
const aName = a?.name ?? "";
|
|
51
|
+
const bName = b?.name ?? "";
|
|
52
|
+
return aName.localeCompare(bName);
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
76
55
|
...channel,
|
|
77
|
-
|
|
56
|
+
messages: sortedMessages,
|
|
78
57
|
};
|
|
79
|
-
}
|
|
80
|
-
|
|
58
|
+
});
|
|
59
|
+
channels.sort((a, b) => {
|
|
60
|
+
const aName = a?.name ?? "";
|
|
61
|
+
const bName = b?.name ?? "";
|
|
62
|
+
return aName.localeCompare(bName);
|
|
63
|
+
});
|
|
64
|
+
websocket.channels = channels;
|
|
81
65
|
return doc;
|
|
82
66
|
}
|
|
83
67
|
function normalizeGraphqlOperations(doc) {
|
|
@@ -86,20 +70,24 @@ function normalizeGraphqlOperations(doc) {
|
|
|
86
70
|
}
|
|
87
71
|
const protocols = doc.service.protocols;
|
|
88
72
|
const graphql = protocols.graphql;
|
|
89
|
-
if (!graphql
|
|
73
|
+
if (!graphql) {
|
|
90
74
|
return doc;
|
|
91
75
|
}
|
|
92
|
-
const kinds = [
|
|
76
|
+
const kinds = [
|
|
77
|
+
"queries",
|
|
78
|
+
"mutations",
|
|
79
|
+
"subscriptions",
|
|
80
|
+
];
|
|
93
81
|
for (const kind of kinds) {
|
|
94
|
-
const
|
|
95
|
-
if (!
|
|
82
|
+
const ops = graphql[kind];
|
|
83
|
+
if (!Array.isArray(ops)) {
|
|
96
84
|
continue;
|
|
97
85
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
86
|
+
graphql[kind] = [...ops].sort((a, b) => {
|
|
87
|
+
const aName = a?.name ?? "";
|
|
88
|
+
const bName = b?.name ?? "";
|
|
89
|
+
return aName.localeCompare(bName);
|
|
90
|
+
});
|
|
103
91
|
}
|
|
104
92
|
return doc;
|
|
105
93
|
}
|
|
@@ -112,5 +100,5 @@ function normalizeGraphqlOperations(doc) {
|
|
|
112
100
|
*/
|
|
113
101
|
export function normalizeUniSpec(doc, _options = {}) {
|
|
114
102
|
const normalized = normalizeValue(doc);
|
|
115
|
-
return normalizeWebSocket(normalizeGraphqlOperations(
|
|
103
|
+
return normalizeWebSocket(normalizeGraphqlOperations(normalizeRestRoutes(normalized)));
|
|
116
104
|
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,37 +1,87 @@
|
|
|
1
|
-
export interface
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
mutations?: Record<string, unknown>;
|
|
7
|
-
subscriptions?: Record<string, unknown>;
|
|
1
|
+
export interface UniSpecGraphQLOperation {
|
|
2
|
+
name: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
deprecated?: boolean;
|
|
5
|
+
deprecationReason?: string;
|
|
8
6
|
}
|
|
9
7
|
export interface UniSpecGraphQLProtocol {
|
|
10
|
-
schema?:
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
schema?: string;
|
|
9
|
+
queries?: UniSpecGraphQLOperation[];
|
|
10
|
+
mutations?: UniSpecGraphQLOperation[];
|
|
11
|
+
subscriptions?: UniSpecGraphQLOperation[];
|
|
12
|
+
}
|
|
13
|
+
export interface UniSpecSecurityRequirement extends Array<string> {
|
|
13
14
|
}
|
|
14
15
|
export interface UniSpecWebSocketMessage {
|
|
15
|
-
name
|
|
16
|
-
summary?: string;
|
|
16
|
+
name: string;
|
|
17
17
|
description?: string;
|
|
18
|
-
|
|
19
|
-
extensions?: Record<string, unknown>;
|
|
18
|
+
schemaRef?: string;
|
|
20
19
|
}
|
|
21
20
|
export interface UniSpecWebSocketChannel {
|
|
22
|
-
|
|
23
|
-
title?: string;
|
|
21
|
+
name: string;
|
|
24
22
|
description?: string;
|
|
25
|
-
direction?:
|
|
23
|
+
direction?: "publish" | "subscribe" | "both";
|
|
26
24
|
messages?: UniSpecWebSocketMessage[];
|
|
25
|
+
security?: UniSpecSecurityRequirement[];
|
|
27
26
|
extensions?: Record<string, unknown>;
|
|
28
27
|
}
|
|
29
28
|
export interface UniSpecWebSocketProtocol {
|
|
30
|
-
channels?:
|
|
31
|
-
|
|
29
|
+
channels?: UniSpecWebSocketChannel[];
|
|
30
|
+
securitySchemes?: Record<string, Record<string, unknown>>;
|
|
31
|
+
}
|
|
32
|
+
export interface UniSpecRestParameter {
|
|
33
|
+
name: string;
|
|
34
|
+
description?: string;
|
|
35
|
+
required?: boolean;
|
|
36
|
+
schemaRef?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface UniSpecRestMediaType {
|
|
39
|
+
schemaRef?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface UniSpecRestContent {
|
|
42
|
+
[mediaType: string]: UniSpecRestMediaType;
|
|
43
|
+
}
|
|
44
|
+
export interface UniSpecRestRequestBody {
|
|
45
|
+
description?: string;
|
|
46
|
+
required?: boolean;
|
|
47
|
+
content?: UniSpecRestContent;
|
|
48
|
+
}
|
|
49
|
+
export interface UniSpecRestResponse {
|
|
50
|
+
description?: string;
|
|
51
|
+
content?: UniSpecRestContent;
|
|
52
|
+
}
|
|
53
|
+
export interface UniSpecRestRoute {
|
|
54
|
+
name?: string;
|
|
55
|
+
summary?: string;
|
|
56
|
+
description?: string;
|
|
57
|
+
path: string;
|
|
58
|
+
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
59
|
+
pathParams?: UniSpecRestParameter[];
|
|
60
|
+
queryParams?: UniSpecRestParameter[];
|
|
61
|
+
headers?: UniSpecRestParameter[];
|
|
62
|
+
requestBody?: UniSpecRestRequestBody;
|
|
63
|
+
responses?: Record<string, UniSpecRestResponse>;
|
|
64
|
+
security?: UniSpecSecurityRequirement[];
|
|
65
|
+
}
|
|
66
|
+
export interface UniSpecRestProtocol {
|
|
67
|
+
routes?: UniSpecRestRoute[];
|
|
68
|
+
securitySchemes?: Record<string, Record<string, unknown>>;
|
|
69
|
+
}
|
|
70
|
+
export interface UniSpecSchemaDefinition {
|
|
71
|
+
jsonSchema: Record<string, unknown>;
|
|
72
|
+
}
|
|
73
|
+
export interface UniSpecSchemas {
|
|
74
|
+
[name: string]: UniSpecSchemaDefinition;
|
|
75
|
+
}
|
|
76
|
+
export interface UniSpecEnvironment {
|
|
77
|
+
name: string;
|
|
78
|
+
baseUrl: string;
|
|
79
|
+
region?: string;
|
|
80
|
+
labels?: Record<string, string>;
|
|
81
|
+
isDefault?: boolean;
|
|
32
82
|
}
|
|
33
83
|
export interface UniSpecServiceProtocols {
|
|
34
|
-
rest?:
|
|
84
|
+
rest?: UniSpecRestProtocol;
|
|
35
85
|
graphql?: UniSpecGraphQLProtocol;
|
|
36
86
|
websocket?: UniSpecWebSocketProtocol;
|
|
37
87
|
}
|
|
@@ -39,7 +89,13 @@ export interface UniSpecService {
|
|
|
39
89
|
name: string;
|
|
40
90
|
title?: string;
|
|
41
91
|
description?: string;
|
|
92
|
+
version?: string;
|
|
93
|
+
owner?: string;
|
|
94
|
+
tags?: string[];
|
|
95
|
+
links?: Record<string, string>;
|
|
42
96
|
protocols?: UniSpecServiceProtocols;
|
|
97
|
+
schemas?: UniSpecSchemas;
|
|
98
|
+
environments?: UniSpecEnvironment[];
|
|
43
99
|
}
|
|
44
100
|
export interface UniSpecDocument {
|
|
45
101
|
unispecVersion: string;
|
|
@@ -55,3 +111,88 @@ export interface ValidationResult {
|
|
|
55
111
|
valid: boolean;
|
|
56
112
|
errors: ValidationError[];
|
|
57
113
|
}
|
|
114
|
+
export type UniSpecTestProtocol = "rest" | "graphql" | "websocket";
|
|
115
|
+
export interface UniSpecTestTarget {
|
|
116
|
+
protocol: UniSpecTestProtocol;
|
|
117
|
+
operationId: string;
|
|
118
|
+
environment?: string;
|
|
119
|
+
}
|
|
120
|
+
export interface UniSpecRestTestParams {
|
|
121
|
+
path?: Record<string, unknown>;
|
|
122
|
+
query?: Record<string, unknown>;
|
|
123
|
+
}
|
|
124
|
+
export interface UniSpecRestTestRequest {
|
|
125
|
+
params?: UniSpecRestTestParams;
|
|
126
|
+
headers?: Record<string, unknown>;
|
|
127
|
+
body?: unknown;
|
|
128
|
+
authProfile?: string;
|
|
129
|
+
}
|
|
130
|
+
export type UniSpecRestExpectedStatus = number | number[];
|
|
131
|
+
export type UniSpecRestBodyExpectationMode = "exact" | "contains" | "schemaOnly" | "snapshot";
|
|
132
|
+
export interface UniSpecRestBodyExpectation {
|
|
133
|
+
mode?: UniSpecRestBodyExpectationMode;
|
|
134
|
+
json?: unknown;
|
|
135
|
+
schemaRef?: string;
|
|
136
|
+
}
|
|
137
|
+
export interface UniSpecRestTestExpect {
|
|
138
|
+
status: UniSpecRestExpectedStatus;
|
|
139
|
+
headers?: Record<string, unknown>;
|
|
140
|
+
body?: UniSpecRestBodyExpectation;
|
|
141
|
+
}
|
|
142
|
+
export interface UniSpecGraphQLTestRequest {
|
|
143
|
+
operationName?: string;
|
|
144
|
+
query: string;
|
|
145
|
+
variables?: Record<string, unknown>;
|
|
146
|
+
headers?: Record<string, unknown>;
|
|
147
|
+
authProfile?: string;
|
|
148
|
+
}
|
|
149
|
+
export interface UniSpecGraphQLTestExpect {
|
|
150
|
+
data?: unknown;
|
|
151
|
+
errors?: unknown;
|
|
152
|
+
bodyMode?: string;
|
|
153
|
+
}
|
|
154
|
+
export type UniSpecWebSocketTestMessageActionType = "send" | "expect";
|
|
155
|
+
export interface UniSpecWebSocketTestMessageAction {
|
|
156
|
+
type: UniSpecWebSocketTestMessageActionType;
|
|
157
|
+
messageName: string;
|
|
158
|
+
payload?: unknown;
|
|
159
|
+
}
|
|
160
|
+
export interface UniSpecWebSocketTestRequest {
|
|
161
|
+
channel: string;
|
|
162
|
+
direction?: "publish" | "subscribe" | "both";
|
|
163
|
+
messages?: UniSpecWebSocketTestMessageAction[];
|
|
164
|
+
authProfile?: string;
|
|
165
|
+
}
|
|
166
|
+
export interface UniSpecWebSocketTestExpect {
|
|
167
|
+
messages?: UniSpecWebSocketTestMessageAction[];
|
|
168
|
+
timeoutMs?: number;
|
|
169
|
+
}
|
|
170
|
+
export interface UniSpecTestRequest {
|
|
171
|
+
rest?: UniSpecRestTestRequest;
|
|
172
|
+
graphql?: UniSpecGraphQLTestRequest;
|
|
173
|
+
websocket?: UniSpecWebSocketTestRequest;
|
|
174
|
+
}
|
|
175
|
+
export interface UniSpecTestExpect {
|
|
176
|
+
rest?: UniSpecRestTestExpect;
|
|
177
|
+
graphql?: UniSpecGraphQLTestExpect;
|
|
178
|
+
websocket?: UniSpecWebSocketTestExpect;
|
|
179
|
+
}
|
|
180
|
+
export interface UniSpecTestCase {
|
|
181
|
+
name: string;
|
|
182
|
+
description?: string;
|
|
183
|
+
target: UniSpecTestTarget;
|
|
184
|
+
request: UniSpecTestRequest;
|
|
185
|
+
expect: UniSpecTestExpect;
|
|
186
|
+
tags?: string[];
|
|
187
|
+
extensions?: Record<string, unknown>;
|
|
188
|
+
}
|
|
189
|
+
export interface UniSpecTestsDocument {
|
|
190
|
+
uniSpecTestsVersion: string;
|
|
191
|
+
target: {
|
|
192
|
+
serviceName: string;
|
|
193
|
+
serviceVersion?: string;
|
|
194
|
+
environment?: string;
|
|
195
|
+
};
|
|
196
|
+
tests: UniSpecTestCase[];
|
|
197
|
+
extensions?: Record<string, unknown>;
|
|
198
|
+
}
|
package/dist/types/index.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { UniSpecDocument, ValidationResult } from "../types";
|
|
1
|
+
import { UniSpecDocument, UniSpecTestsDocument, ValidationResult } from "../types";
|
|
2
2
|
export interface ValidateOptions {
|
|
3
3
|
}
|
|
4
4
|
/**
|
|
5
5
|
* Validate a UniSpec document against the UniSpec JSON Schema.
|
|
6
6
|
*/
|
|
7
7
|
export declare function validateUniSpec(doc: UniSpecDocument, _options?: ValidateOptions): Promise<ValidationResult>;
|
|
8
|
+
/**
|
|
9
|
+
* Validate a UniSpec Tests document against the UniSpec Tests JSON Schema.
|
|
10
|
+
*/
|
|
11
|
+
export declare function validateUniSpecTests(doc: UniSpecTestsDocument, _options?: ValidateOptions): Promise<ValidationResult>;
|
package/dist/validator/index.js
CHANGED
|
@@ -6,6 +6,8 @@ const ajv = new Ajv2020({
|
|
|
6
6
|
allErrors: true,
|
|
7
7
|
strict: true,
|
|
8
8
|
});
|
|
9
|
+
// Register minimal URI format to satisfy UniSpec schemas (service.environments[*].baseUrl)
|
|
10
|
+
ajv.addFormat("uri", true);
|
|
9
11
|
// Register all UniSpec subschemas so that Ajv can resolve internal $ref links
|
|
10
12
|
try {
|
|
11
13
|
const schemaRootPath = require.resolve("@unispechq/unispec-schema");
|
|
@@ -20,6 +22,7 @@ catch {
|
|
|
20
22
|
// parts of the schema that do not rely on those $ref references.
|
|
21
23
|
}
|
|
22
24
|
const validateFn = ajv.compile(unispecSchema);
|
|
25
|
+
let validateTestsFn;
|
|
23
26
|
function mapAjvErrors(errors) {
|
|
24
27
|
if (!errors)
|
|
25
28
|
return [];
|
|
@@ -45,3 +48,25 @@ export async function validateUniSpec(doc, _options = {}) {
|
|
|
45
48
|
errors: mapAjvErrors(validateFn.errors),
|
|
46
49
|
};
|
|
47
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Validate a UniSpec Tests document against the UniSpec Tests JSON Schema.
|
|
53
|
+
*/
|
|
54
|
+
export async function validateUniSpecTests(doc, _options = {}) {
|
|
55
|
+
if (!validateTestsFn) {
|
|
56
|
+
const schemaRootPath = require.resolve("@unispechq/unispec-schema");
|
|
57
|
+
const schemaDir = schemaRootPath.replace(/index\.(cjs|mjs|js)$/u, "schema/");
|
|
58
|
+
const testsSchema = require(`${schemaDir}unispec-tests.schema.json`);
|
|
59
|
+
validateTestsFn = ajv.compile(testsSchema);
|
|
60
|
+
}
|
|
61
|
+
const valid = validateTestsFn(doc);
|
|
62
|
+
if (valid) {
|
|
63
|
+
return {
|
|
64
|
+
valid: true,
|
|
65
|
+
errors: [],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
valid: false,
|
|
70
|
+
errors: mapAjvErrors(validateTestsFn.errors),
|
|
71
|
+
};
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unispechq/unispec-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Central UniSpec Core Engine providing parsing, validation, normalization, diffing, and conversion of UniSpec specs.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"release:major": "node scripts/release.js major"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@unispechq/unispec-schema": "^0.
|
|
22
|
+
"@unispechq/unispec-schema": "^0.3.1",
|
|
23
23
|
"ajv": "^8.12.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|