@unispechq/unispec-core 0.1.1 → 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.d.ts +13 -0
- package/dist/converters/index.js +201 -0
- package/dist/diff/index.d.ts +21 -0
- package/dist/diff/index.js +233 -0
- package/dist/index.js +6 -0
- package/dist/loader/index.d.ts +13 -0
- package/dist/loader/index.js +19 -0
- package/dist/normalizer/index.d.ts +11 -0
- package/dist/normalizer/index.js +104 -0
- package/dist/types/index.d.ts +198 -0
- package/dist/types/index.js +2 -0
- package/dist/validator/index.d.ts +11 -0
- package/dist/validator/index.js +72 -0
- package/package.json +6 -2
- package/.github/workflows/npm-publish.yml +0 -74
- package/.windsurfrules +0 -138
- package/scripts/release.js +0 -51
- package/src/converters/index.ts +0 -120
- package/src/diff/index.ts +0 -235
- package/src/loader/index.ts +0 -25
- package/src/normalizer/index.ts +0 -156
- package/src/types/index.ts +0 -67
- package/src/validator/index.ts +0 -61
- package/tests/converters.test.mjs +0 -126
- package/tests/diff.test.mjs +0 -240
- package/tests/loader-validator.test.mjs +0 -19
- package/tests/normalizer.test.mjs +0 -115
- package/tsconfig.json +0 -15
- /package/{src/index.ts → dist/index.d.ts} +0 -0
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.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { UniSpecDocument } from "../types";
|
|
2
|
+
export interface OpenAPIDocument {
|
|
3
|
+
[key: string]: unknown;
|
|
4
|
+
}
|
|
5
|
+
export interface GraphQLSDLOutput {
|
|
6
|
+
sdl: string;
|
|
7
|
+
}
|
|
8
|
+
export interface WebSocketModel {
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
export declare function toOpenAPI(doc: UniSpecDocument): OpenAPIDocument;
|
|
12
|
+
export declare function toGraphQLSDL(doc: UniSpecDocument): GraphQLSDLOutput;
|
|
13
|
+
export declare function toWebSocketModel(doc: UniSpecDocument): WebSocketModel;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
export function toOpenAPI(doc) {
|
|
2
|
+
const service = doc.service;
|
|
3
|
+
const rest = (service.protocols?.rest ?? {});
|
|
4
|
+
const info = {
|
|
5
|
+
title: service.title ?? service.name,
|
|
6
|
+
description: service.description,
|
|
7
|
+
};
|
|
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
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
openapi: "3.1.0",
|
|
131
|
+
info,
|
|
132
|
+
servers,
|
|
133
|
+
paths,
|
|
134
|
+
...(Object.keys(components).length > 0 ? { components } : {}),
|
|
135
|
+
"x-unispec": doc,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
export function toGraphQLSDL(doc) {
|
|
139
|
+
// Minimal implementation: generate a basic SDL that exposes service metadata
|
|
140
|
+
// via a Query field. This does not attempt to interpret the full GraphQL
|
|
141
|
+
// protocol structure yet, but provides a stable, deterministic SDL shape
|
|
142
|
+
// based on top-level UniSpec document fields.
|
|
143
|
+
const graphql = doc.service.protocols?.graphql;
|
|
144
|
+
const customSDL = graphql?.schema;
|
|
145
|
+
if (typeof customSDL === "string" && customSDL.trim()) {
|
|
146
|
+
return { sdl: customSDL };
|
|
147
|
+
}
|
|
148
|
+
const service = doc.service;
|
|
149
|
+
const title = service.title ?? service.name;
|
|
150
|
+
const description = service.description ?? "";
|
|
151
|
+
const lines = [];
|
|
152
|
+
if (title || description) {
|
|
153
|
+
lines.push("\"\"");
|
|
154
|
+
if (title) {
|
|
155
|
+
lines.push(title);
|
|
156
|
+
}
|
|
157
|
+
if (description) {
|
|
158
|
+
lines.push("");
|
|
159
|
+
lines.push(description);
|
|
160
|
+
}
|
|
161
|
+
lines.push("\"\"");
|
|
162
|
+
}
|
|
163
|
+
lines.push("schema {");
|
|
164
|
+
lines.push(" query: Query");
|
|
165
|
+
lines.push("}");
|
|
166
|
+
lines.push("");
|
|
167
|
+
lines.push("type Query {");
|
|
168
|
+
lines.push(" _serviceInfo: String!\n");
|
|
169
|
+
lines.push("}");
|
|
170
|
+
const sdl = lines.join("\n");
|
|
171
|
+
return { sdl };
|
|
172
|
+
}
|
|
173
|
+
export function toWebSocketModel(doc) {
|
|
174
|
+
// Base WebSocket model intended for a modern, dashboard-oriented UI.
|
|
175
|
+
// It exposes service metadata, a normalized list of channels and the raw
|
|
176
|
+
// websocket protocol object, while also embedding the original UniSpec
|
|
177
|
+
// document under a technical key for debugging and introspection.
|
|
178
|
+
const service = doc.service;
|
|
179
|
+
const websocket = (service.protocols?.websocket ?? {});
|
|
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
|
+
}));
|
|
191
|
+
return {
|
|
192
|
+
service: {
|
|
193
|
+
name: service.name,
|
|
194
|
+
title: service.title,
|
|
195
|
+
description: service.description,
|
|
196
|
+
},
|
|
197
|
+
channels,
|
|
198
|
+
rawProtocol: websocket,
|
|
199
|
+
"x-unispec-ws": doc,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { UniSpecDocument } from "../types";
|
|
2
|
+
export type ChangeSeverity = "breaking" | "non-breaking" | "unknown";
|
|
3
|
+
export interface UniSpecChange {
|
|
4
|
+
path: string;
|
|
5
|
+
description: string;
|
|
6
|
+
severity: ChangeSeverity;
|
|
7
|
+
protocol?: "rest" | "graphql" | "websocket";
|
|
8
|
+
kind?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface DiffResult {
|
|
11
|
+
changes: UniSpecChange[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Compute a structural diff between two UniSpec documents.
|
|
15
|
+
*
|
|
16
|
+
* Current behavior:
|
|
17
|
+
* - Tracks added, removed, and changed fields and array items.
|
|
18
|
+
* - Uses JSON Pointer-like paths rooted at "" (e.g., "/info/title").
|
|
19
|
+
* - Marks all changes with severity "unknown" for now.
|
|
20
|
+
*/
|
|
21
|
+
export declare function diffUniSpec(oldDoc: UniSpecDocument, newDoc: UniSpecDocument): DiffResult;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
function isPlainObject(value) {
|
|
2
|
+
return Object.prototype.toString.call(value) === "[object Object]";
|
|
3
|
+
}
|
|
4
|
+
function diffValues(oldVal, newVal, basePath, out) {
|
|
5
|
+
if (oldVal === newVal) {
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
// Both plain objects → recurse by keys
|
|
9
|
+
if (isPlainObject(oldVal) && isPlainObject(newVal)) {
|
|
10
|
+
const oldKeys = new Set(Object.keys(oldVal));
|
|
11
|
+
const newKeys = new Set(Object.keys(newVal));
|
|
12
|
+
// Removed keys
|
|
13
|
+
for (const key of oldKeys) {
|
|
14
|
+
if (!newKeys.has(key)) {
|
|
15
|
+
out.push({
|
|
16
|
+
path: `${basePath}/${key}`,
|
|
17
|
+
description: "Field removed",
|
|
18
|
+
severity: "unknown",
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// Added / changed keys
|
|
23
|
+
for (const key of newKeys) {
|
|
24
|
+
const childPath = `${basePath}/${key}`;
|
|
25
|
+
if (!oldKeys.has(key)) {
|
|
26
|
+
out.push({
|
|
27
|
+
path: childPath,
|
|
28
|
+
description: "Field added",
|
|
29
|
+
severity: "unknown",
|
|
30
|
+
});
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
diffValues(oldVal[key], newVal[key], childPath, out);
|
|
34
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
// Arrays
|
|
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
|
|
85
|
+
const maxLen = Math.max(oldVal.length, newVal.length);
|
|
86
|
+
for (let i = 0; i < maxLen; i++) {
|
|
87
|
+
const childPath = `${basePath}/${i}`;
|
|
88
|
+
if (i >= oldVal.length) {
|
|
89
|
+
out.push({
|
|
90
|
+
path: childPath,
|
|
91
|
+
description: "Item added",
|
|
92
|
+
severity: "unknown",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
else if (i >= newVal.length) {
|
|
96
|
+
out.push({
|
|
97
|
+
path: childPath,
|
|
98
|
+
description: "Item removed",
|
|
99
|
+
severity: "unknown",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
diffValues(oldVal[i], newVal[i], childPath, out);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// Primitive or mismatched types → treat as value change
|
|
109
|
+
out.push({
|
|
110
|
+
path: basePath,
|
|
111
|
+
description: "Value changed",
|
|
112
|
+
severity: "unknown",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function annotateRestChange(change) {
|
|
116
|
+
if (!change.path.startsWith("/service/protocols/rest/routes/")) {
|
|
117
|
+
return change;
|
|
118
|
+
}
|
|
119
|
+
const segments = change.path.split("/").filter(Boolean);
|
|
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") {
|
|
126
|
+
return change;
|
|
127
|
+
}
|
|
128
|
+
const annotated = {
|
|
129
|
+
...change,
|
|
130
|
+
protocol: "rest",
|
|
131
|
+
};
|
|
132
|
+
if (change.description === "Item removed" || change.description === "Field removed") {
|
|
133
|
+
annotated.kind = "rest.route.removed";
|
|
134
|
+
annotated.severity = "breaking";
|
|
135
|
+
}
|
|
136
|
+
else if (change.description === "Item added" || change.description === "Field added") {
|
|
137
|
+
annotated.kind = "rest.route.added";
|
|
138
|
+
annotated.severity = "non-breaking";
|
|
139
|
+
}
|
|
140
|
+
return annotated;
|
|
141
|
+
}
|
|
142
|
+
function annotateWebSocketChange(change) {
|
|
143
|
+
if (!change.path.startsWith("/service/protocols/websocket/channels/")) {
|
|
144
|
+
return change;
|
|
145
|
+
}
|
|
146
|
+
const segments = change.path.split("/").filter(Boolean);
|
|
147
|
+
// Expected base: ["service","protocols","websocket","channels", channelIndex, ...]
|
|
148
|
+
if (segments[0] !== "service" || segments[1] !== "protocols" || segments[2] !== "websocket" || segments[3] !== "channels") {
|
|
149
|
+
return change;
|
|
150
|
+
}
|
|
151
|
+
const channelIndex = segments[4];
|
|
152
|
+
const next = segments[5];
|
|
153
|
+
const annotated = {
|
|
154
|
+
...change,
|
|
155
|
+
protocol: "websocket",
|
|
156
|
+
};
|
|
157
|
+
if (typeof channelIndex === "undefined") {
|
|
158
|
+
return annotated;
|
|
159
|
+
}
|
|
160
|
+
// Channel-level changes: /service/protocols/websocket/channels/{index}
|
|
161
|
+
if (!next) {
|
|
162
|
+
if (change.description === "Item removed" || change.description === "Field removed") {
|
|
163
|
+
annotated.kind = "websocket.channel.removed";
|
|
164
|
+
annotated.severity = "breaking";
|
|
165
|
+
}
|
|
166
|
+
else if (change.description === "Item added" || change.description === "Field added") {
|
|
167
|
+
annotated.kind = "websocket.channel.added";
|
|
168
|
+
annotated.severity = "non-breaking";
|
|
169
|
+
}
|
|
170
|
+
return annotated;
|
|
171
|
+
}
|
|
172
|
+
// Message-level changes: /service/protocols/websocket/channels/{index}/messages/{msgIndex}
|
|
173
|
+
if (next === "messages") {
|
|
174
|
+
const messageIndex = segments[6];
|
|
175
|
+
if (typeof messageIndex === "undefined") {
|
|
176
|
+
return annotated;
|
|
177
|
+
}
|
|
178
|
+
if (change.description === "Item removed") {
|
|
179
|
+
annotated.kind = "websocket.message.removed";
|
|
180
|
+
annotated.severity = "breaking";
|
|
181
|
+
}
|
|
182
|
+
else if (change.description === "Item added") {
|
|
183
|
+
annotated.kind = "websocket.message.added";
|
|
184
|
+
annotated.severity = "non-breaking";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return annotated;
|
|
188
|
+
}
|
|
189
|
+
function annotateGraphQLChange(change) {
|
|
190
|
+
if (!change.path.startsWith("/service/protocols/graphql/")) {
|
|
191
|
+
return change;
|
|
192
|
+
}
|
|
193
|
+
const segments = change.path.split("/").filter(Boolean);
|
|
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") {
|
|
201
|
+
return change;
|
|
202
|
+
}
|
|
203
|
+
if (opKind !== "queries" && opKind !== "mutations" && opKind !== "subscriptions") {
|
|
204
|
+
return change;
|
|
205
|
+
}
|
|
206
|
+
const annotated = {
|
|
207
|
+
...change,
|
|
208
|
+
protocol: "graphql",
|
|
209
|
+
};
|
|
210
|
+
if (change.description === "Item removed" || change.description === "Field removed") {
|
|
211
|
+
annotated.kind = "graphql.operation.removed";
|
|
212
|
+
annotated.severity = "breaking";
|
|
213
|
+
}
|
|
214
|
+
else if (change.description === "Item added" || change.description === "Field added") {
|
|
215
|
+
annotated.kind = "graphql.operation.added";
|
|
216
|
+
annotated.severity = "non-breaking";
|
|
217
|
+
}
|
|
218
|
+
return annotated;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Compute a structural diff between two UniSpec documents.
|
|
222
|
+
*
|
|
223
|
+
* Current behavior:
|
|
224
|
+
* - Tracks added, removed, and changed fields and array items.
|
|
225
|
+
* - Uses JSON Pointer-like paths rooted at "" (e.g., "/info/title").
|
|
226
|
+
* - Marks all changes with severity "unknown" for now.
|
|
227
|
+
*/
|
|
228
|
+
export function diffUniSpec(oldDoc, newDoc) {
|
|
229
|
+
const changes = [];
|
|
230
|
+
diffValues(oldDoc, newDoc, "", changes);
|
|
231
|
+
const annotated = changes.map((change) => annotateWebSocketChange(annotateGraphQLChange(annotateRestChange(change))));
|
|
232
|
+
return { changes: annotated };
|
|
233
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { UniSpecDocument } from "../types";
|
|
2
|
+
export interface LoadOptions {
|
|
3
|
+
filename?: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Load a UniSpec document from a raw input value.
|
|
7
|
+
* Currently supports:
|
|
8
|
+
* - JavaScript objects (treated as already parsed UniSpec)
|
|
9
|
+
* - JSON strings
|
|
10
|
+
*
|
|
11
|
+
* YAML and filesystem helpers will be added later, keeping this API stable.
|
|
12
|
+
*/
|
|
13
|
+
export declare function loadUniSpec(input: string | object, _options?: LoadOptions): Promise<UniSpecDocument>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load a UniSpec document from a raw input value.
|
|
3
|
+
* Currently supports:
|
|
4
|
+
* - JavaScript objects (treated as already parsed UniSpec)
|
|
5
|
+
* - JSON strings
|
|
6
|
+
*
|
|
7
|
+
* YAML and filesystem helpers will be added later, keeping this API stable.
|
|
8
|
+
*/
|
|
9
|
+
export async function loadUniSpec(input, _options = {}) {
|
|
10
|
+
if (typeof input === "string") {
|
|
11
|
+
const trimmed = input.trim();
|
|
12
|
+
if (!trimmed) {
|
|
13
|
+
throw new Error("Cannot load UniSpec: input string is empty");
|
|
14
|
+
}
|
|
15
|
+
// For now we assume JSON; YAML support will be added later.
|
|
16
|
+
return JSON.parse(trimmed);
|
|
17
|
+
}
|
|
18
|
+
return input;
|
|
19
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { UniSpecDocument } from "../types";
|
|
2
|
+
export interface NormalizeOptions {
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Normalize a UniSpec document into a canonical, deterministic form.
|
|
6
|
+
*
|
|
7
|
+
* Current behavior:
|
|
8
|
+
* - Recursively sorts object keys lexicographically.
|
|
9
|
+
* - Preserves values as-is.
|
|
10
|
+
*/
|
|
11
|
+
export declare function normalizeUniSpec(doc: UniSpecDocument, _options?: NormalizeOptions): UniSpecDocument;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
function isPlainObject(value) {
|
|
2
|
+
return Object.prototype.toString.call(value) === "[object Object]";
|
|
3
|
+
}
|
|
4
|
+
function normalizeValue(value) {
|
|
5
|
+
if (Array.isArray(value)) {
|
|
6
|
+
return value.map((item) => normalizeValue(item));
|
|
7
|
+
}
|
|
8
|
+
if (isPlainObject(value)) {
|
|
9
|
+
const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
|
|
10
|
+
const normalized = {};
|
|
11
|
+
for (const [key, val] of entries) {
|
|
12
|
+
normalized[key] = normalizeValue(val);
|
|
13
|
+
}
|
|
14
|
+
return normalized;
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function normalizeRestRoutes(doc) {
|
|
19
|
+
if (!doc || !doc.service || !doc.service.protocols) {
|
|
20
|
+
return doc;
|
|
21
|
+
}
|
|
22
|
+
const protocols = doc.service.protocols;
|
|
23
|
+
const rest = protocols.rest;
|
|
24
|
+
if (!rest || !Array.isArray(rest.routes)) {
|
|
25
|
+
return doc;
|
|
26
|
+
}
|
|
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;
|
|
34
|
+
return doc;
|
|
35
|
+
}
|
|
36
|
+
function normalizeWebSocket(doc) {
|
|
37
|
+
if (!doc || !doc.service || !doc.service.protocols) {
|
|
38
|
+
return doc;
|
|
39
|
+
}
|
|
40
|
+
const protocols = doc.service.protocols;
|
|
41
|
+
const websocket = protocols.websocket;
|
|
42
|
+
if (!websocket || !Array.isArray(websocket.channels)) {
|
|
43
|
+
return doc;
|
|
44
|
+
}
|
|
45
|
+
const channels = websocket.channels.map((channel) => {
|
|
46
|
+
if (!channel || !Array.isArray(channel.messages)) {
|
|
47
|
+
return channel;
|
|
48
|
+
}
|
|
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 {
|
|
55
|
+
...channel,
|
|
56
|
+
messages: sortedMessages,
|
|
57
|
+
};
|
|
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;
|
|
65
|
+
return doc;
|
|
66
|
+
}
|
|
67
|
+
function normalizeGraphqlOperations(doc) {
|
|
68
|
+
if (!doc || !doc.service || !doc.service.protocols) {
|
|
69
|
+
return doc;
|
|
70
|
+
}
|
|
71
|
+
const protocols = doc.service.protocols;
|
|
72
|
+
const graphql = protocols.graphql;
|
|
73
|
+
if (!graphql) {
|
|
74
|
+
return doc;
|
|
75
|
+
}
|
|
76
|
+
const kinds = [
|
|
77
|
+
"queries",
|
|
78
|
+
"mutations",
|
|
79
|
+
"subscriptions",
|
|
80
|
+
];
|
|
81
|
+
for (const kind of kinds) {
|
|
82
|
+
const ops = graphql[kind];
|
|
83
|
+
if (!Array.isArray(ops)) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
graphql[kind] = [...ops].sort((a, b) => {
|
|
87
|
+
const aName = a?.name ?? "";
|
|
88
|
+
const bName = b?.name ?? "";
|
|
89
|
+
return aName.localeCompare(bName);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return doc;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Normalize a UniSpec document into a canonical, deterministic form.
|
|
96
|
+
*
|
|
97
|
+
* Current behavior:
|
|
98
|
+
* - Recursively sorts object keys lexicographically.
|
|
99
|
+
* - Preserves values as-is.
|
|
100
|
+
*/
|
|
101
|
+
export function normalizeUniSpec(doc, _options = {}) {
|
|
102
|
+
const normalized = normalizeValue(doc);
|
|
103
|
+
return normalizeWebSocket(normalizeGraphqlOperations(normalizeRestRoutes(normalized)));
|
|
104
|
+
}
|