toolcraft-openapi 0.0.13 → 0.0.15

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.
@@ -2,6 +2,9 @@ import { type HttpMethod } from "./naming.js";
2
2
  type OpenApiOperation = OpenApiOperationObject | OpenApiReferenceObject;
3
3
  type OpenApiOperationMap = Partial<Record<HttpMethod, OpenApiOperation>>;
4
4
  type OpenApiParameterLocation = "path" | "query" | "header" | "cookie";
5
+ type OpenApiScalarType = "string" | "number" | "integer" | "boolean";
6
+ type OpenApiSchemaType = OpenApiScalarType | "object" | "array";
7
+ type OpenApiJsonSchemaType = OpenApiSchemaType | "null";
5
8
  export type GeneratedRequestLocation = Exclude<GeneratedParam["location"], "transport">;
6
9
  export interface OpenApiDocument {
7
10
  openapi?: string;
@@ -57,7 +60,7 @@ export interface OpenApiMediaTypeObject {
57
60
  schema?: OpenApiSchemaObject | OpenApiReferenceObject;
58
61
  }
59
62
  export interface OpenApiSchemaObject {
60
- type?: "string" | "number" | "integer" | "boolean" | "object" | "array";
63
+ type?: OpenApiJsonSchemaType | readonly OpenApiJsonSchemaType[];
61
64
  additionalProperties?: boolean | OpenApiSchemaObject | OpenApiReferenceObject;
62
65
  allOf?: Array<OpenApiSchemaObject | OpenApiReferenceObject>;
63
66
  anyOf?: Array<OpenApiSchemaObject | OpenApiReferenceObject>;
@@ -120,6 +123,7 @@ export interface GeneratedParam {
120
123
  description?: string;
121
124
  shortFlag?: string;
122
125
  scope?: readonly [GeneratedParamScope, ...GeneratedParamScope[]];
126
+ global?: boolean;
123
127
  optional: boolean;
124
128
  definition: GeneratedParamDefinition;
125
129
  }
@@ -184,6 +188,7 @@ interface RenderSchemaOptionsInput {
184
188
  description?: string;
185
189
  shortFlag?: string;
186
190
  scope?: readonly [GeneratedParamScope, ...GeneratedParamScope[]];
191
+ global?: boolean;
187
192
  }
188
193
  export type GeneratedPreflightBlock = {
189
194
  kind: "scalar-null";
package/dist/generate.js CHANGED
@@ -25,6 +25,7 @@ const TRANSPORT_PARAMS = [
25
25
  location: "transport",
26
26
  description: "Print the HTTP request and exit without sending it.",
27
27
  scope: ["cli", "sdk"],
28
+ global: true,
28
29
  optional: true,
29
30
  definition: { kind: "boolean" }
30
31
  },
@@ -35,6 +36,7 @@ const TRANSPORT_PARAMS = [
35
36
  description: "Log the request line to stderr.",
36
37
  shortFlag: "v",
37
38
  scope: ["cli", "sdk"],
39
+ global: true,
38
40
  optional: true,
39
41
  definition: { kind: "boolean" }
40
42
  }
@@ -56,6 +58,10 @@ const SCHEMA_OPTION_SOURCES = [
56
58
  key: "scope",
57
59
  get: (param) => param.scope
58
60
  },
61
+ {
62
+ key: "global",
63
+ get: (param) => (param.global === true ? true : undefined)
64
+ },
59
65
  {
60
66
  key: "minimum",
61
67
  get: (param) => param.definition.minimum
@@ -324,17 +330,11 @@ function assertSupportedSuccessResponseSchema(document, schema, operationId, con
324
330
  }
325
331
  }
326
332
  function expectSupportedSuccessResponseSchema(document, schema, operationId, context) {
327
- const resolvedSchema = resolveSchema(document, schema, operationId, context);
333
+ const resolvedSchema = normalizeNullableSchema(document, resolveSchema(document, schema, operationId, context), operationId, context);
328
334
  const compositionKeyword = getCompositionKeyword(resolvedSchema);
329
335
  if (compositionKeyword === undefined) {
330
336
  return resolvedSchema;
331
337
  }
332
- const nullableAnyOfSchema = compositionKeyword === "anyOf"
333
- ? resolveNullableAnyOfSchema(document, resolvedSchema, operationId, context)
334
- : undefined;
335
- if (nullableAnyOfSchema !== undefined) {
336
- return nullableAnyOfSchema;
337
- }
338
338
  throw new UserError(`Operation ${JSON.stringify(operationId)} uses unsupported ${context}. JSON Schema composition keyword ${JSON.stringify(compositionKeyword)} is not supported in v1.`);
339
339
  }
340
340
  function createGeneratedParameter(document, parameter, operationId) {
@@ -578,6 +578,9 @@ function createCollectedRequestBodyParams(assemblies, bodyOptional, requestBodyD
578
578
  };
579
579
  }
580
580
  function createParamDefinition(document, schema, operationId, context) {
581
+ if (Array.isArray(schema.type)) {
582
+ throw new UserError(`Operation ${JSON.stringify(operationId)} uses unsupported ${context}. JSON Schema type arrays with multiple non-null types are not supported in v1.`);
583
+ }
581
584
  if (schema.type === "array") {
582
585
  const itemSchema = expectArrayItemsSchema(document, schema, operationId, context);
583
586
  return {
@@ -589,9 +592,9 @@ function createParamDefinition(document, schema, operationId, context) {
589
592
  ...(schema.nullable === true ? { nullable: true } : {})
590
593
  };
591
594
  }
592
- const scalarDefinition = schema.type === undefined || !(schema.type in SCHEMA_TYPE_TO_KIND)
593
- ? undefined
594
- : SCHEMA_TYPE_TO_KIND[schema.type];
595
+ const scalarDefinition = isOpenApiScalarType(schema.type)
596
+ ? SCHEMA_TYPE_TO_KIND[schema.type]
597
+ : undefined;
595
598
  const enumValues = normalizeEnumValues(schema.enum, operationId, context, schema.nullable === true, schema.type);
596
599
  if (enumValues !== undefined) {
597
600
  return {
@@ -747,6 +750,9 @@ function expectOperation(document, operation, method, path, refChain = []) {
747
750
  function isEnumPrimitiveValue(value) {
748
751
  return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
749
752
  }
753
+ function isOpenApiScalarType(type) {
754
+ return typeof type === "string" && type in SCHEMA_TYPE_TO_KIND;
755
+ }
750
756
  function expectRequestBody(document, requestBody, operationId, context, refChain = []) {
751
757
  if (!isReferenceObject(requestBody)) {
752
758
  return requestBody;
@@ -763,7 +769,7 @@ function expectResponse(document, response, operationId, statusCode, refChain =
763
769
  return expectResponse(document, resolveLocalReference(document, response.$ref, operationId, context), operationId, statusCode, [...refChain, response.$ref]);
764
770
  }
765
771
  function expectSchema(document, schema, operationId, context, refChain = []) {
766
- const resolvedSchema = resolveSchema(document, schema, operationId, context, refChain);
772
+ const resolvedSchema = normalizeNullableSchema(document, resolveSchema(document, schema, operationId, context, refChain), operationId, context);
767
773
  const compositionKeyword = getCompositionKeyword(resolvedSchema);
768
774
  if (compositionKeyword !== undefined) {
769
775
  throw new UserError(`Operation ${JSON.stringify(operationId)} uses unsupported ${context}. JSON Schema composition keyword ${JSON.stringify(compositionKeyword)} is not supported in v1.`);
@@ -780,6 +786,28 @@ function resolveSchema(document, schema, operationId, context, refChain = []) {
780
786
  }
781
787
  return schema;
782
788
  }
789
+ function normalizeNullableSchema(document, schema, operationId, context) {
790
+ const typeNormalizedSchema = normalizeNullableTypeArray(schema);
791
+ if (getCompositionKeyword(typeNormalizedSchema) !== "anyOf") {
792
+ return typeNormalizedSchema;
793
+ }
794
+ return (resolveNullableAnyOfSchema(document, typeNormalizedSchema, operationId, context) ??
795
+ typeNormalizedSchema);
796
+ }
797
+ function normalizeNullableTypeArray(schema) {
798
+ if (!Array.isArray(schema.type)) {
799
+ return schema;
800
+ }
801
+ const nonNullTypes = schema.type.filter((type) => type !== "null");
802
+ if (nonNullTypes.length === schema.type.length) {
803
+ return schema;
804
+ }
805
+ return {
806
+ ...schema,
807
+ type: nonNullTypes.length === 1 ? nonNullTypes[0] : nonNullTypes,
808
+ nullable: true
809
+ };
810
+ }
783
811
  function getCompositionKeyword(schema) {
784
812
  for (const keyword of ["allOf", "anyOf", "oneOf"]) {
785
813
  if (schema[keyword] !== undefined) {
@@ -793,7 +821,7 @@ function resolveNullableAnyOfSchema(document, schema, operationId, context) {
793
821
  if (variants === undefined || variants.length !== 2) {
794
822
  return undefined;
795
823
  }
796
- const resolvedVariants = variants.map((variant, index) => resolveSchema(document, variant, operationId, `${context} anyOf variant ${index}`));
824
+ const resolvedVariants = variants.map((variant, index) => normalizeNullableSchema(document, resolveSchema(document, variant, operationId, `${context} anyOf variant ${index}`), operationId, `${context} anyOf variant ${index}`));
797
825
  const nullVariantIndex = resolvedVariants.findIndex(isExplicitNullSchema);
798
826
  if (nullVariantIndex === -1) {
799
827
  return undefined;
@@ -802,8 +830,14 @@ function resolveNullableAnyOfSchema(document, schema, operationId, context) {
802
830
  if (nonNullVariant === undefined || getCompositionKeyword(nonNullVariant) !== undefined) {
803
831
  return undefined;
804
832
  }
833
+ const { anyOf: _anyOf, nullable: _nullable, ...wrapperSchema } = schema;
834
+ void _anyOf;
835
+ void _nullable;
805
836
  return {
837
+ ...wrapperSchema,
806
838
  ...nonNullVariant,
839
+ description: nonNullVariant.description ?? schema.description,
840
+ default: nonNullVariant.default ?? schema.default,
807
841
  nullable: true
808
842
  };
809
843
  }
@@ -935,15 +969,16 @@ function renderParamLines(params) {
935
969
  return params.map((param) => ` ${renderObjectKey(param.paramName)}: ${renderParamSchema(param)},`);
936
970
  }
937
971
  function renderParamSchema(param) {
938
- const schema = renderDefinition(param.definition, param.description, param.shortFlag, param.scope);
972
+ const schema = renderDefinition(param.definition, param.description, param.shortFlag, param.scope, param.global);
939
973
  return param.optional ? `S.Optional(${schema})` : schema;
940
974
  }
941
- function renderDefinition(definition, description, shortFlag, scope) {
975
+ function renderDefinition(definition, description, shortFlag, scope, global) {
942
976
  const options = renderSchemaOptions({
943
977
  definition,
944
978
  description,
945
979
  shortFlag,
946
- scope
980
+ scope,
981
+ global
947
982
  });
948
983
  const renderer = DEFINITION_RENDERERS[definition.kind];
949
984
  return renderer(definition, options);
package/dist/http.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { text as designText } from "@poe-code/design-system";
1
2
  import { UserError } from "toolcraft";
2
3
  export class HttpError extends Error {
3
4
  status;
@@ -20,7 +21,7 @@ export async function requestJson(options) {
20
21
  const writeStderr = options.writeStderr ?? process.stderr.write.bind(process.stderr);
21
22
  const requestLine = `${method} ${url}`;
22
23
  if (options.verbose) {
23
- writeStderr(`${requestLine}\n`);
24
+ writeStderr(`${designText.muted(requestLine)}\n`);
24
25
  }
25
26
  if (options.dryRun) {
26
27
  writeStdout(formatDryRunOutput(requestLine, headers, options.body));
@@ -0,0 +1,35 @@
1
+ import type { OpenApiDocument } from "../generate.js";
2
+ import { type OpenApiSourceFileSystem } from "../spec-source.js";
3
+ export type OnUnmocked = "throw" | "reply404";
4
+ export interface MockFixtureEntry {
5
+ status?: number;
6
+ headers?: Record<string, string>;
7
+ body?: unknown;
8
+ }
9
+ export type MockFetchFixtures = Record<string, MockFixtureEntry> | string;
10
+ export interface MockFetchFileSystem extends OpenApiSourceFileSystem {
11
+ readdir?(directory: string): Promise<string[]>;
12
+ }
13
+ export interface MockFetchOptions {
14
+ spec: OpenApiDocument | string | URL;
15
+ fixtures?: MockFetchFixtures;
16
+ onUnmocked?: OnUnmocked;
17
+ cwd?: string;
18
+ fs?: MockFetchFileSystem;
19
+ fetch?: typeof globalThis.fetch;
20
+ }
21
+ export interface RequestRecord {
22
+ method: string;
23
+ path: string;
24
+ operationId: string;
25
+ headers: Record<string, string>;
26
+ body: unknown;
27
+ at: Date;
28
+ }
29
+ export interface MockFetchHandle {
30
+ fetch: typeof globalThis.fetch;
31
+ requests: RequestRecord[];
32
+ reset(): void;
33
+ }
34
+ export declare function mockFetch(options: MockFetchOptions): Promise<MockFetchHandle>;
35
+ export type { OpenApiSourceFileSystem };
@@ -0,0 +1,498 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { UserError } from "toolcraft";
4
+ import { parseOpenApiDocument, readOpenApiSourceText } from "../spec-source.js";
5
+ const HTTP_METHOD_NAMES = ["get", "post", "put", "patch", "delete"];
6
+ export async function mockFetch(options) {
7
+ const document = await resolveSpec(options);
8
+ const operations = compileOperations(document);
9
+ const operationIds = new Set(operations.map((op) => op.operationId));
10
+ const fixtureLoader = await createFixtureLoader(options.fixtures, options.cwd, options.fs, operationIds);
11
+ const onUnmocked = options.onUnmocked ?? "throw";
12
+ const requests = [];
13
+ const fetchImpl = async (input, init) => {
14
+ const requestUrl = parseRequestUrl(input);
15
+ const method = (init?.method ?? getRequestMethod(input) ?? "GET").toUpperCase();
16
+ const headers = collectHeaders(input, init);
17
+ const bodyText = await readRequestBody(input, init);
18
+ const parsedBody = parseJsonOrUndefined(bodyText);
19
+ const matchingPath = operations.filter((op) => op.pathRegex.test(requestUrl.pathname));
20
+ if (matchingPath.length === 0) {
21
+ throw new MockFetchError(`mockFetch: no operation in the spec matches ${method} ${requestUrl.pathname}.`);
22
+ }
23
+ const operation = matchingPath.find((op) => op.method === method);
24
+ if (operation === undefined) {
25
+ const allowed = matchingPath.map((op) => op.method).join(", ");
26
+ throw new MockFetchError(`mockFetch: method ${method} not declared for ${requestUrl.pathname} (spec allows ${allowed}).`);
27
+ }
28
+ requests.push({
29
+ method,
30
+ path: requestUrl.pathname,
31
+ operationId: operation.operationId,
32
+ headers,
33
+ body: parsedBody,
34
+ at: new Date()
35
+ });
36
+ if (operation.requestBodySchema !== undefined && parsedBody !== undefined) {
37
+ const errors = validateAgainstSchema(parsedBody, operation.requestBodySchema, document, "$");
38
+ if (errors.length > 0) {
39
+ return jsonResponse(422, { errors });
40
+ }
41
+ }
42
+ else if (operation.requestBodyRequired && parsedBody === undefined) {
43
+ return jsonResponse(422, { errors: ["$: request body is required"] });
44
+ }
45
+ const fixture = await fixtureLoader(operation.operationId);
46
+ if (fixture !== undefined) {
47
+ const status = fixture.status ?? operation.defaultStatus;
48
+ const responseSchema = operation.responseSchemas.get(status);
49
+ if (responseSchema !== undefined && fixture.body !== undefined) {
50
+ const errors = validateAgainstSchema(fixture.body, responseSchema, document, "$response");
51
+ if (errors.length > 0) {
52
+ throw new MockFetchError(`mockFetch: response fixture for ${JSON.stringify(operation.operationId)} ` +
53
+ `violates the spec response schema for status ${status}:\n ${errors.join("\n ")}`);
54
+ }
55
+ }
56
+ return buildResponse(fixture, operation.defaultStatus);
57
+ }
58
+ if (operation.defaultExample !== undefined) {
59
+ return buildResponse({ body: operation.defaultExample }, operation.defaultStatus);
60
+ }
61
+ if (onUnmocked === "reply404") {
62
+ return jsonResponse(404, { error: `unmocked: ${operation.operationId}` });
63
+ }
64
+ throw new MockFetchError(`mockFetch: unmocked operation ${JSON.stringify(operation.operationId)}. ` +
65
+ `Add a fixture, an OpenAPI example on the success response, or pass { onUnmocked: "reply404" }.`);
66
+ };
67
+ return {
68
+ fetch: fetchImpl,
69
+ requests,
70
+ reset() {
71
+ requests.length = 0;
72
+ }
73
+ };
74
+ }
75
+ class MockFetchError extends Error {
76
+ constructor(message) {
77
+ super(message);
78
+ this.name = "MockFetchError";
79
+ }
80
+ }
81
+ async function resolveSpec(options) {
82
+ const { spec } = options;
83
+ if (typeof spec !== "string" && !(spec instanceof URL)) {
84
+ return spec;
85
+ }
86
+ const sourceText = await readOpenApiSourceText(spec, {
87
+ cwd: options.cwd ?? process.cwd(),
88
+ fetch: options.fetch ?? globalThis.fetch,
89
+ fs: options.fs ?? fs
90
+ });
91
+ return parseOpenApiDocument(sourceText, spec);
92
+ }
93
+ function compileOperations(document) {
94
+ const paths = document.paths;
95
+ if (paths === undefined) {
96
+ throw new UserError('mockFetch: OpenAPI document must define a top-level "paths" object.');
97
+ }
98
+ const compiled = [];
99
+ for (const [pathTemplate, pathItem] of Object.entries(paths)) {
100
+ if (pathItem === undefined) {
101
+ continue;
102
+ }
103
+ for (const method of HTTP_METHOD_NAMES) {
104
+ const operation = pathItem[method];
105
+ if (operation === undefined) {
106
+ continue;
107
+ }
108
+ const resolvedOperation = resolveOperation(operation, document);
109
+ const operationId = resolvedOperation.operationId ?? `${method.toUpperCase()} ${pathTemplate}`;
110
+ const { defaultStatus, defaultExample, responseSchemas } = pickResponseMetadata(resolvedOperation, document);
111
+ const { schema: requestBodySchema, required: requestBodyRequired } = pickRequestBody(resolvedOperation, document);
112
+ compiled.push({
113
+ method: method.toUpperCase(),
114
+ pathTemplate,
115
+ pathRegex: pathTemplateToRegex(pathTemplate),
116
+ pathSpecificity: countPathPlaceholders(pathTemplate),
117
+ operationId,
118
+ operation: resolvedOperation,
119
+ defaultStatus,
120
+ defaultExample,
121
+ requestBodySchema,
122
+ requestBodyRequired,
123
+ responseSchemas
124
+ });
125
+ }
126
+ }
127
+ // Literal paths win over templated paths when both match the same pathname.
128
+ // Sort ascending by placeholder count so concrete operations are matched first.
129
+ compiled.sort((a, b) => a.pathSpecificity - b.pathSpecificity);
130
+ return compiled;
131
+ }
132
+ function countPathPlaceholders(template) {
133
+ return (template.match(/\{[^}]+\}/g) ?? []).length;
134
+ }
135
+ function pathTemplateToRegex(template) {
136
+ // Escape regex metacharacters except for "{...}" placeholders, which become non-slash captures.
137
+ const pattern = template.replace(/[.*+?^${}()|[\]\\]/g, (match) => match === "{" || match === "}" ? match : `\\${match}`);
138
+ const withParams = pattern.replace(/\{[^}]+\}/g, "[^/]+");
139
+ return new RegExp(`^${withParams}$`);
140
+ }
141
+ function resolveOperation(operation, document) {
142
+ if (isReference(operation)) {
143
+ return resolveReference(operation, document);
144
+ }
145
+ return operation;
146
+ }
147
+ function pickResponseMetadata(operation, document) {
148
+ const responses = operation.responses ?? {};
149
+ const responseSchemas = new Map();
150
+ for (const [code, response] of Object.entries(responses)) {
151
+ const status = parseInt(code, 10);
152
+ if (!Number.isFinite(status) || response === undefined) {
153
+ continue;
154
+ }
155
+ const resolved = isReference(response)
156
+ ? resolveReference(response, document)
157
+ : response;
158
+ const schema = extractResponseSchema(resolved, document);
159
+ if (schema !== undefined) {
160
+ responseSchemas.set(status, schema);
161
+ }
162
+ }
163
+ const successCodes = Object.keys(responses)
164
+ .map((code) => parseInt(code, 10))
165
+ .filter((code) => Number.isFinite(code) && code >= 200 && code < 300)
166
+ .sort((a, b) => a - b);
167
+ if (successCodes.length === 0) {
168
+ return { defaultStatus: 200, defaultExample: undefined, responseSchemas };
169
+ }
170
+ const status = successCodes[0];
171
+ const response = responses[String(status)];
172
+ const resolvedResponse = response !== undefined && isReference(response)
173
+ ? resolveReference(response, document)
174
+ : response;
175
+ return {
176
+ defaultStatus: status,
177
+ defaultExample: extractExample(resolvedResponse, document),
178
+ responseSchemas
179
+ };
180
+ }
181
+ function extractResponseSchema(response, document) {
182
+ if (response === undefined) {
183
+ return undefined;
184
+ }
185
+ const media = pickJsonMediaType(response.content);
186
+ if (media === undefined || media.schema === undefined) {
187
+ return undefined;
188
+ }
189
+ return isReference(media.schema)
190
+ ? resolveReference(media.schema, document)
191
+ : media.schema;
192
+ }
193
+ function pickRequestBody(operation, document) {
194
+ const raw = operation.requestBody;
195
+ if (raw === undefined) {
196
+ return { schema: undefined, required: false };
197
+ }
198
+ const resolved = isReference(raw)
199
+ ? resolveReference(raw, document)
200
+ : raw;
201
+ const media = pickJsonMediaType(resolved.content);
202
+ if (media === undefined) {
203
+ return { schema: undefined, required: resolved.required === true };
204
+ }
205
+ if (media.schema === undefined) {
206
+ return { schema: undefined, required: resolved.required === true };
207
+ }
208
+ const schema = isReference(media.schema)
209
+ ? resolveReference(media.schema, document)
210
+ : media.schema;
211
+ return { schema, required: resolved.required === true };
212
+ }
213
+ function extractExample(response, document) {
214
+ if (response === undefined) {
215
+ return undefined;
216
+ }
217
+ const media = pickJsonMediaType(response.content);
218
+ if (media === undefined) {
219
+ return undefined;
220
+ }
221
+ if (media.example !== undefined) {
222
+ return media.example;
223
+ }
224
+ if (media.examples !== undefined) {
225
+ for (const value of Object.values(media.examples)) {
226
+ if (value !== undefined && "value" in value) {
227
+ return value.value;
228
+ }
229
+ }
230
+ }
231
+ if (media.schema !== undefined) {
232
+ const schema = isReference(media.schema)
233
+ ? resolveReference(media.schema, document)
234
+ : media.schema;
235
+ const schemaExample = schema.example;
236
+ if (schemaExample !== undefined) {
237
+ return schemaExample;
238
+ }
239
+ }
240
+ return undefined;
241
+ }
242
+ function pickJsonMediaType(content) {
243
+ if (content === undefined) {
244
+ return undefined;
245
+ }
246
+ for (const [type, media] of Object.entries(content)) {
247
+ if (media !== undefined && /application\/json|\+json/i.test(type)) {
248
+ return media;
249
+ }
250
+ }
251
+ return undefined;
252
+ }
253
+ function isReference(value) {
254
+ return (typeof value === "object" &&
255
+ value !== null &&
256
+ "$ref" in value &&
257
+ typeof value.$ref === "string");
258
+ }
259
+ function resolveReference(reference, document) {
260
+ const ref = reference.$ref;
261
+ if (!ref.startsWith("#/")) {
262
+ throw new UserError(`mockFetch: only local $ref values are supported, got ${JSON.stringify(ref)}.`);
263
+ }
264
+ const segments = ref
265
+ .slice(2)
266
+ .split("/")
267
+ .map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
268
+ let current = document;
269
+ for (const segment of segments) {
270
+ if (current === null || typeof current !== "object") {
271
+ throw new UserError(`mockFetch: failed to resolve $ref ${JSON.stringify(ref)}.`);
272
+ }
273
+ current = current[segment];
274
+ }
275
+ if (current === undefined) {
276
+ throw new UserError(`mockFetch: failed to resolve $ref ${JSON.stringify(ref)}.`);
277
+ }
278
+ return current;
279
+ }
280
+ function validateAgainstSchema(value, schema, document, pointer) {
281
+ const resolved = isReference(schema)
282
+ ? resolveReference(schema, document)
283
+ : schema;
284
+ if (resolved.anyOf !== undefined) {
285
+ return resolved.anyOf.some((branch) => validateAgainstSchema(value, branch, document, pointer).length === 0)
286
+ ? []
287
+ : [`${pointer}: did not match any anyOf branch`];
288
+ }
289
+ if (resolved.oneOf !== undefined) {
290
+ const matches = resolved.oneOf.filter((branch) => validateAgainstSchema(value, branch, document, pointer).length === 0);
291
+ return matches.length === 1 ? [] : [`${pointer}: matched ${matches.length} oneOf branches`];
292
+ }
293
+ const errors = [];
294
+ if (resolved.allOf !== undefined) {
295
+ for (const branch of resolved.allOf) {
296
+ errors.push(...validateAgainstSchema(value, branch, document, pointer));
297
+ }
298
+ }
299
+ const types = normalizeTypes(resolved);
300
+ if (types.length > 0 && !types.some((type) => matchesPrimitiveType(value, type))) {
301
+ errors.push(`${pointer}: expected ${types.join(" or ")}, got ${describeValue(value)}`);
302
+ return errors;
303
+ }
304
+ if (types.includes("object") && typeof value === "object" && value !== null && !Array.isArray(value)) {
305
+ for (const required of resolved.required ?? []) {
306
+ if (!(required in value)) {
307
+ errors.push(`${pointer}/${required}: required`);
308
+ }
309
+ }
310
+ if (resolved.properties !== undefined) {
311
+ for (const [key, propValue] of Object.entries(value)) {
312
+ const propSchema = resolved.properties[key];
313
+ if (propSchema !== undefined) {
314
+ errors.push(...validateAgainstSchema(propValue, propSchema, document, `${pointer}/${key}`));
315
+ }
316
+ }
317
+ }
318
+ }
319
+ if (types.includes("array") && Array.isArray(value) && resolved.items !== undefined) {
320
+ for (let i = 0; i < value.length; i++) {
321
+ errors.push(...validateAgainstSchema(value[i], resolved.items, document, `${pointer}/${i}`));
322
+ }
323
+ }
324
+ if (resolved.enum !== undefined && !resolved.enum.includes(value)) {
325
+ errors.push(`${pointer}: not in enum`);
326
+ }
327
+ return errors;
328
+ }
329
+ function normalizeTypes(schema) {
330
+ const type = schema.type;
331
+ if (type === undefined) {
332
+ return [];
333
+ }
334
+ const list = Array.isArray(type) ? type.slice() : [type];
335
+ if (schema.nullable === true && !list.includes("null")) {
336
+ list.push("null");
337
+ }
338
+ return list;
339
+ }
340
+ function matchesPrimitiveType(value, type) {
341
+ switch (type) {
342
+ case "object":
343
+ return typeof value === "object" && value !== null && !Array.isArray(value);
344
+ case "array":
345
+ return Array.isArray(value);
346
+ case "string":
347
+ return typeof value === "string";
348
+ case "number":
349
+ return typeof value === "number" && !Number.isNaN(value);
350
+ case "integer":
351
+ return typeof value === "number" && Number.isInteger(value);
352
+ case "boolean":
353
+ return typeof value === "boolean";
354
+ case "null":
355
+ return value === null;
356
+ default:
357
+ return true;
358
+ }
359
+ }
360
+ function describeValue(value) {
361
+ if (value === null)
362
+ return "null";
363
+ if (Array.isArray(value))
364
+ return "array";
365
+ return typeof value;
366
+ }
367
+ function parseRequestUrl(input) {
368
+ if (input instanceof URL) {
369
+ return input;
370
+ }
371
+ if (typeof input === "string") {
372
+ return new URL(input);
373
+ }
374
+ return new URL(input.url);
375
+ }
376
+ function getRequestMethod(input) {
377
+ if (typeof input === "string" || input instanceof URL) {
378
+ return undefined;
379
+ }
380
+ return input.method;
381
+ }
382
+ function collectHeaders(input, init) {
383
+ const headers = {};
384
+ const initHeaders = init?.headers;
385
+ const requestHeaders = typeof input !== "string" && !(input instanceof URL) ? input.headers : undefined;
386
+ appendHeaders(headers, requestHeaders);
387
+ appendHeaders(headers, initHeaders);
388
+ return headers;
389
+ }
390
+ function appendHeaders(target, source) {
391
+ if (source === undefined) {
392
+ return;
393
+ }
394
+ if (source instanceof Headers) {
395
+ source.forEach((value, key) => {
396
+ target[key.toLowerCase()] = value;
397
+ });
398
+ return;
399
+ }
400
+ if (Array.isArray(source)) {
401
+ for (const [key, value] of source) {
402
+ target[key.toLowerCase()] = value;
403
+ }
404
+ return;
405
+ }
406
+ for (const [key, value] of Object.entries(source)) {
407
+ target[key.toLowerCase()] = String(value);
408
+ }
409
+ }
410
+ async function readRequestBody(input, init) {
411
+ if (init?.body !== undefined && init.body !== null) {
412
+ return typeof init.body === "string" ? init.body : await new Response(init.body).text();
413
+ }
414
+ if (typeof input !== "string" && !(input instanceof URL)) {
415
+ return await input.clone().text();
416
+ }
417
+ return undefined;
418
+ }
419
+ function parseJsonOrUndefined(text) {
420
+ if (text === undefined || text.length === 0) {
421
+ return undefined;
422
+ }
423
+ try {
424
+ return JSON.parse(text);
425
+ }
426
+ catch {
427
+ return text;
428
+ }
429
+ }
430
+ function buildResponse(fixture, defaultStatus) {
431
+ const status = fixture.status ?? defaultStatus;
432
+ const headers = new Headers(fixture.headers ?? { "content-type": "application/json" });
433
+ const body = fixture.body === undefined ? null : fixture.body === null ? null : JSON.stringify(fixture.body);
434
+ return new Response(body, { status, headers });
435
+ }
436
+ function jsonResponse(status, body) {
437
+ return new Response(JSON.stringify(body), {
438
+ status,
439
+ headers: { "content-type": "application/json" }
440
+ });
441
+ }
442
+ async function createFixtureLoader(fixtures, cwd, injectedFs, operationIds) {
443
+ if (fixtures === undefined) {
444
+ return async () => undefined;
445
+ }
446
+ if (typeof fixtures !== "string") {
447
+ rejectUnknownFixtureKeys(Object.keys(fixtures), operationIds, "fixture key");
448
+ const map = fixtures;
449
+ return async (operationId) => map[operationId];
450
+ }
451
+ const directory = path.resolve(cwd ?? process.cwd(), fixtures);
452
+ const fileSystem = injectedFs ?? fs;
453
+ const readdir = fileSystem.readdir?.bind(fileSystem);
454
+ if (readdir === undefined) {
455
+ throw new UserError("mockFetch: directory fixtures require an fs implementation that exposes readdir.");
456
+ }
457
+ let entries;
458
+ try {
459
+ entries = await readdir(directory);
460
+ }
461
+ catch (error) {
462
+ if (isNotFoundError(error)) {
463
+ return async () => undefined;
464
+ }
465
+ throw error;
466
+ }
467
+ const fixtureFiles = entries.filter((entry) => entry.endsWith(".json"));
468
+ const operationIdsFromFiles = fixtureFiles.map((entry) => entry.slice(0, -".json".length));
469
+ rejectUnknownFixtureKeys(operationIdsFromFiles, operationIds, "fixture file");
470
+ const cache = new Map();
471
+ for (const entry of fixtureFiles) {
472
+ const operationId = entry.slice(0, -".json".length);
473
+ const filePath = path.join(directory, entry);
474
+ const contents = await fileSystem.readFile(filePath, "utf8");
475
+ cache.set(operationId, JSON.parse(contents));
476
+ }
477
+ return async (operationId) => cache.get(operationId);
478
+ }
479
+ function rejectUnknownFixtureKeys(candidates, operationIds, label) {
480
+ const unknown = candidates.filter((key) => !operationIds.has(key));
481
+ if (unknown.length === 0) {
482
+ return;
483
+ }
484
+ const sorted = [...operationIds].sort();
485
+ throw new UserError(`mockFetch: ${unknown.length === 1 ? label : `${label}s`} ${formatList(unknown)} ` +
486
+ `${unknown.length === 1 ? "is" : "are"} not declared in the spec. ` +
487
+ `Known operationIds: ${sorted.length === 0 ? "(none)" : formatList(sorted)}.`);
488
+ }
489
+ function formatList(values) {
490
+ return values.map((value) => JSON.stringify(value)).join(", ");
491
+ }
492
+ function isNotFoundError(error) {
493
+ if (typeof error !== "object" || error === null) {
494
+ return false;
495
+ }
496
+ const code = error.code;
497
+ return code === "ENOENT" || code === "ENOTDIR";
498
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import { mockFetch } from "./mock.js";
2
+ const ignoredHandlePromise = mockFetch({
3
+ spec: { openapi: "3.0.0", info: { title: "T", version: "0" }, paths: {} },
4
+ fixtures: { whoami: { body: { handle: "x" } } },
5
+ onUnmocked: "throw"
6
+ });
7
+ void ignoredHandlePromise.then((handle) => {
8
+ void handle.fetch;
9
+ void handle.requests;
10
+ void handle.reset;
11
+ });
12
+ const ignoredOptions = { spec: "./openapi.json" };
13
+ void ignoredOptions;
14
+ const ignoredFixture = { status: 200, body: { ok: true } };
15
+ void ignoredFixture;
package/dist/mock.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { mockFetch } from "./mock/fetch.js";
2
+ export type { MockFetchHandle, MockFetchOptions, MockFetchFixtures, MockFixtureEntry, OnUnmocked, RequestRecord } from "./mock/fetch.js";
package/dist/mock.js ADDED
@@ -0,0 +1 @@
1
+ export { mockFetch } from "./mock/fetch.js";
package/dist/runtime.js CHANGED
@@ -66,11 +66,11 @@ function createRuntimeHandler(command) {
66
66
  };
67
67
  }
68
68
  function createRuntimeParamSchema(param) {
69
- const definition = createRuntimeDefinition(param.definition, param.description, param.shortFlag, param.scope);
69
+ const definition = createRuntimeDefinition(param.definition, param.description, param.shortFlag, param.scope, param.global);
70
70
  return param.optional ? S.Optional(definition) : definition;
71
71
  }
72
- function createRuntimeDefinition(definition, description, shortFlag, scope) {
73
- const options = createRuntimeSchemaOptions(definition, description, shortFlag, scope);
72
+ function createRuntimeDefinition(definition, description, shortFlag, scope, global) {
73
+ const options = createRuntimeSchemaOptions(definition, description, shortFlag, scope, global);
74
74
  return RUNTIME_DEFINITION_BUILDERS[definition.kind](definition, options);
75
75
  }
76
76
  const RUNTIME_DEFINITION_BUILDERS = {
@@ -83,12 +83,13 @@ const RUNTIME_DEFINITION_BUILDERS = {
83
83
  number: (_definition, options) => options === undefined ? S.Number() : S.Number(options),
84
84
  string: (_definition, options) => options === undefined ? S.String() : S.String(options)
85
85
  };
86
- function createRuntimeSchemaOptions(definition, description, shortFlag, scope) {
86
+ function createRuntimeSchemaOptions(definition, description, shortFlag, scope, global) {
87
87
  const options = Object.fromEntries(collectSchemaOptionEntries({
88
88
  definition,
89
89
  description,
90
90
  shortFlag,
91
- scope
91
+ scope,
92
+ global
92
93
  }).map(({ key, value }) => [key, Array.isArray(value) ? [...value] : value]));
93
94
  return Object.keys(options).length === 0 ? undefined : options;
94
95
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolcraft-openapi",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -8,12 +8,16 @@
8
8
  ".": {
9
9
  "types": "./dist/index.d.ts",
10
10
  "import": "./dist/index.js"
11
+ },
12
+ "./mock": {
13
+ "types": "./dist/mock.d.ts",
14
+ "import": "./dist/mock.js"
11
15
  }
12
16
  },
13
17
  "scripts": {
14
18
  "build": "rm -rf dist && tsc",
15
- "test": "cd ../.. && vitest run packages/toolcraft-openapi/src/*.test.ts",
16
- "test:unit": "cd ../.. && vitest run packages/toolcraft-openapi/src/*.test.ts",
19
+ "test": "cd ../.. && vitest run packages/toolcraft-openapi/src",
20
+ "test:unit": "cd ../.. && vitest run packages/toolcraft-openapi/src",
17
21
  "prepack": "node ../../scripts/manage-bundled-workspace-deps.mjs prepare . @poe-code/design-system auth-store",
18
22
  "postpack": "node ../../scripts/manage-bundled-workspace-deps.mjs cleanup . @poe-code/design-system auth-store"
19
23
  },
@@ -30,7 +34,7 @@
30
34
  "auth-store": "^0.0.1",
31
35
  "chalk": "^5.6.2",
32
36
  "console-table-printer": "^2.15.0",
33
- "toolcraft": "^0.0.13",
37
+ "toolcraft": "^0.0.15",
34
38
  "yaml": "^2.8.2"
35
39
  },
36
40
  "engines": {