@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 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 paths/methods, GraphQL operations, WebSocket channels/messages)
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
- - Treated as a general OpenAPI-like structure defined by UniSpec JSON Schemas.
81
- - Normalizer orders `paths` and HTTP methods for stable diffs.
82
- - Diff engine annotates path/operation additions and removals with breaking/non-breaking severity.
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 `operations` (queries/mutations/subscriptions).
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, plus extensions.
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.
@@ -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
- const servers = rest?.servers ?? [];
9
- const paths = rest?.paths ?? {};
10
- // Transparently forward additional REST protocol fields into the OpenAPI document.
11
- // This allows users to describe components, security, tags and other structures
12
- // without forcing a specific REST model at the core layer.
13
- const { servers: _omitServers, paths: _omitPaths, ...restExtras } = rest ?? {};
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
- ...restExtras,
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?.sdl;
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 channelsRecord = (websocket && typeof websocket === "object" && websocket.channels && typeof websocket.channels === "object")
66
- ? websocket.channels
67
- : {};
68
- const channels = Object.keys(channelsRecord).sort().map((name) => {
69
- const channel = channelsRecord[name] ?? {};
70
- return {
71
- name,
72
- summary: channel.summary ?? channel.title,
73
- description: channel.description,
74
- direction: channel.direction,
75
- messages: channel.messages,
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,
@@ -34,8 +34,54 @@ function diffValues(oldVal, newVal, basePath, out) {
34
34
  }
35
35
  return;
36
36
  }
37
- // Arrays → shallow compare by index for now
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/paths/")) {
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", "paths", pathKey?, method?]
75
- if (segments[0] !== "service" || segments[1] !== "protocols" || segments[2] !== "rest" || segments[3] !== "paths") {
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
- if (pathKey && !method) {
87
- annotated.kind = "rest.path.removed";
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
- if (pathKey && !method) {
97
- annotated.kind = "rest.path.added";
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", channelName, ...]
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 channelName = segments[4];
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 (!channelName) {
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 (channels/{channelName}/messages/{index})
172
+ // Message-level changes: /service/protocols/websocket/channels/{index}/messages/{msgIndex}
138
173
  if (next === "messages") {
139
- const index = segments[6];
140
- if (typeof index === "undefined") {
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/operations/")) {
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","operations", kind, opName?]
160
- if (segments[0] !== "service" || segments[1] !== "protocols" || segments[2] !== "graphql" || segments[3] !== "operations") {
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
- const opKind = segments[4];
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
  }
@@ -15,37 +15,22 @@ function normalizeValue(value) {
15
15
  }
16
16
  return value;
17
17
  }
18
- function normalizeRestPaths(doc) {
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 || !rest.paths || typeof rest.paths !== "object") {
24
+ if (!rest || !Array.isArray(rest.routes)) {
25
25
  return doc;
26
26
  }
27
- const httpMethodOrder = ["get", "head", "options", "post", "put", "patch", "delete"];
28
- const normalizedPaths = {};
29
- for (const path of Object.keys(rest.paths).sort()) {
30
- const pathItem = rest.paths[path];
31
- if (!pathItem || typeof pathItem !== "object") {
32
- normalizedPaths[path] = pathItem;
33
- continue;
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 || !websocket.channels || typeof websocket.channels !== "object") {
42
+ if (!websocket || !Array.isArray(websocket.channels)) {
58
43
  return doc;
59
44
  }
60
- const originalChannels = websocket.channels;
61
- const sortedChannels = {};
62
- for (const name of Object.keys(originalChannels).sort()) {
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
- sortedChannels[name] = {
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
- ...(messages ? { messages } : {}),
56
+ messages: sortedMessages,
78
57
  };
79
- }
80
- websocket.channels = sortedChannels;
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 || !graphql.operations) {
73
+ if (!graphql) {
90
74
  return doc;
91
75
  }
92
- const kinds = ["queries", "mutations", "subscriptions"];
76
+ const kinds = [
77
+ "queries",
78
+ "mutations",
79
+ "subscriptions",
80
+ ];
93
81
  for (const kind of kinds) {
94
- const bucket = graphql.operations[kind];
95
- if (!bucket || typeof bucket !== "object") {
82
+ const ops = graphql[kind];
83
+ if (!Array.isArray(ops)) {
96
84
  continue;
97
85
  }
98
- const sorted = {};
99
- for (const name of Object.keys(bucket).sort()) {
100
- sorted[name] = bucket[name];
101
- }
102
- graphql.operations[kind] = sorted;
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(normalizeRestPaths(normalized)));
103
+ return normalizeWebSocket(normalizeGraphqlOperations(normalizeRestRoutes(normalized)));
116
104
  }
@@ -1,37 +1,87 @@
1
- export interface UniSpecGraphQLSchema {
2
- sdl?: string;
3
- }
4
- export interface UniSpecGraphQLOperations {
5
- queries?: Record<string, unknown>;
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?: UniSpecGraphQLSchema;
11
- operations?: UniSpecGraphQLOperations;
12
- extensions?: Record<string, unknown>;
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?: string;
16
- summary?: string;
16
+ name: string;
17
17
  description?: string;
18
- payload?: unknown;
19
- extensions?: Record<string, unknown>;
18
+ schemaRef?: string;
20
19
  }
21
20
  export interface UniSpecWebSocketChannel {
22
- summary?: string;
23
- title?: string;
21
+ name: string;
24
22
  description?: string;
25
- direction?: string;
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?: Record<string, UniSpecWebSocketChannel>;
31
- extensions?: Record<string, unknown>;
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?: unknown;
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
+ }
@@ -1 +1,2 @@
1
+ // GraphQL protocol types
1
2
  export {};
@@ -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>;
@@ -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.1.2",
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.2.1",
22
+ "@unispechq/unispec-schema": "^0.3.1",
23
23
  "ajv": "^8.12.0"
24
24
  },
25
25
  "devDependencies": {