@unispechq/unispec-core 0.1.2 → 0.2.2

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.
@@ -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
  }
package/dist/index.cjs ADDED
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./types/index.js"), exports);
18
+ __exportStar(require("./loader/index.js"), exports);
19
+ __exportStar(require("./validator/index.js"), exports);
20
+ __exportStar(require("./normalizer/index.js"), exports);
21
+ __exportStar(require("./diff/index.js"), exports);
22
+ __exportStar(require("./converters/index.js"), exports);
@@ -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>;
@@ -1,18 +1,22 @@
1
1
  import Ajv2020 from "ajv/dist/2020.js";
2
- import { createRequire } from "module";
2
+ import fs from "fs";
3
+ import path from "path";
3
4
  import { unispec as unispecSchema, manifest as unispecManifest } from "@unispechq/unispec-schema";
4
- const require = createRequire(import.meta.url);
5
5
  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
- const schemaRootPath = require.resolve("@unispechq/unispec-schema");
12
- const schemaDir = schemaRootPath.replace(/index\.(cjs|mjs|js)$/u, "schema/");
13
+ const schemaDir = path.join(process.cwd(), "node_modules", "@unispechq", "unispec-schema", "schema");
13
14
  const types = unispecManifest?.types ?? {};
14
15
  const typeSchemaPaths = Object.values(types).map((rel) => String(rel));
15
- const loadedTypeSchemas = typeSchemaPaths.map((relPath) => require(schemaDir + relPath));
16
+ const loadedTypeSchemas = typeSchemaPaths
17
+ .map((relPath) => path.join(schemaDir, relPath))
18
+ .filter((filePath) => fs.existsSync(filePath))
19
+ .map((filePath) => JSON.parse(fs.readFileSync(filePath, "utf8")));
16
20
  ajv.addSchema(loadedTypeSchemas);
17
21
  }
18
22
  catch {
@@ -20,6 +24,7 @@ catch {
20
24
  // parts of the schema that do not rely on those $ref references.
21
25
  }
22
26
  const validateFn = ajv.compile(unispecSchema);
27
+ let validateTestsFn;
23
28
  function mapAjvErrors(errors) {
24
29
  if (!errors)
25
30
  return [];
@@ -45,3 +50,25 @@ export async function validateUniSpec(doc, _options = {}) {
45
50
  errors: mapAjvErrors(validateFn.errors),
46
51
  };
47
52
  }
53
+ /**
54
+ * Validate a UniSpec Tests document against the UniSpec Tests JSON Schema.
55
+ */
56
+ export async function validateUniSpecTests(doc, _options = {}) {
57
+ if (!validateTestsFn) {
58
+ const schemaDir = path.join(process.cwd(), "node_modules", "@unispechq", "unispec-schema", "schema");
59
+ const testsSchemaPath = path.join(schemaDir, "unispec-tests.schema.json");
60
+ const testsSchema = JSON.parse(fs.readFileSync(testsSchemaPath, "utf8"));
61
+ validateTestsFn = ajv.compile(testsSchema);
62
+ }
63
+ const valid = validateTestsFn(doc);
64
+ if (valid) {
65
+ return {
66
+ valid: true,
67
+ errors: [],
68
+ };
69
+ }
70
+ return {
71
+ valid: false,
72
+ errors: mapAjvErrors(validateTestsFn.errors),
73
+ };
74
+ }