apify-schema-tools 2.0.1

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.
Files changed (83) hide show
  1. package/.cspell/custom-dictionary.txt +4 -0
  2. package/.husky/pre-commit +33 -0
  3. package/.node-version +1 -0
  4. package/CHANGELOG.md +88 -0
  5. package/LICENSE +201 -0
  6. package/README.md +312 -0
  7. package/biome.json +31 -0
  8. package/dist/apify-schema-tools.d.ts +3 -0
  9. package/dist/apify-schema-tools.d.ts.map +1 -0
  10. package/dist/apify-schema-tools.js +197 -0
  11. package/dist/apify-schema-tools.js.map +1 -0
  12. package/dist/apify.d.ts +11 -0
  13. package/dist/apify.d.ts.map +1 -0
  14. package/dist/apify.js +107 -0
  15. package/dist/apify.js.map +1 -0
  16. package/dist/configuration.d.ts +43 -0
  17. package/dist/configuration.d.ts.map +1 -0
  18. package/dist/configuration.js +87 -0
  19. package/dist/configuration.js.map +1 -0
  20. package/dist/filesystem.d.ts +8 -0
  21. package/dist/filesystem.d.ts.map +1 -0
  22. package/dist/filesystem.js +16 -0
  23. package/dist/filesystem.js.map +1 -0
  24. package/dist/json-schemas.d.ts +34 -0
  25. package/dist/json-schemas.d.ts.map +1 -0
  26. package/dist/json-schemas.js +185 -0
  27. package/dist/json-schemas.js.map +1 -0
  28. package/dist/typescript.d.ts +26 -0
  29. package/dist/typescript.d.ts.map +1 -0
  30. package/dist/typescript.js +316 -0
  31. package/dist/typescript.js.map +1 -0
  32. package/package.json +60 -0
  33. package/samples/all-defaults/.actor/actor.json +15 -0
  34. package/samples/all-defaults/.actor/dataset_schema.json +32 -0
  35. package/samples/all-defaults/.actor/input_schema.json +53 -0
  36. package/samples/all-defaults/src/generated/dataset.ts +24 -0
  37. package/samples/all-defaults/src/generated/input-utils.ts +60 -0
  38. package/samples/all-defaults/src/generated/input.ts +42 -0
  39. package/samples/all-defaults/src-schemas/dataset-item.json +28 -0
  40. package/samples/all-defaults/src-schemas/input.json +73 -0
  41. package/samples/deep-merged-schemas/.actor/actor.json +15 -0
  42. package/samples/deep-merged-schemas/.actor/dataset_schema.json +37 -0
  43. package/samples/deep-merged-schemas/.actor/input_schema.json +61 -0
  44. package/samples/deep-merged-schemas/add-schemas/dataset-item.json +10 -0
  45. package/samples/deep-merged-schemas/add-schemas/input.json +33 -0
  46. package/samples/deep-merged-schemas/src/generated/dataset.ts +28 -0
  47. package/samples/deep-merged-schemas/src/generated/input-utils.ts +66 -0
  48. package/samples/deep-merged-schemas/src/generated/input.ts +47 -0
  49. package/samples/deep-merged-schemas/src-schemas/dataset-item.json +28 -0
  50. package/samples/deep-merged-schemas/src-schemas/input.json +73 -0
  51. package/samples/merged-schemas/.actor/actor.json +15 -0
  52. package/samples/merged-schemas/.actor/dataset_schema.json +37 -0
  53. package/samples/merged-schemas/.actor/input_schema.json +58 -0
  54. package/samples/merged-schemas/add-schemas/dataset-item.json +10 -0
  55. package/samples/merged-schemas/add-schemas/input.json +33 -0
  56. package/samples/merged-schemas/src/generated/dataset.ts +28 -0
  57. package/samples/merged-schemas/src/generated/input-utils.ts +57 -0
  58. package/samples/merged-schemas/src/generated/input.ts +42 -0
  59. package/samples/merged-schemas/src-schemas/dataset-item.json +28 -0
  60. package/samples/merged-schemas/src-schemas/input.json +73 -0
  61. package/samples/package-json-config/.actor/actor.json +15 -0
  62. package/samples/package-json-config/.actor/dataset_schema.json +32 -0
  63. package/samples/package-json-config/.actor/input_schema.json +53 -0
  64. package/samples/package-json-config/custom-src-schemas/dataset-item.json +28 -0
  65. package/samples/package-json-config/custom-src-schemas/input.json +73 -0
  66. package/samples/package-json-config/package.json +11 -0
  67. package/samples/package-json-config/src/custom-generated/dataset.ts +24 -0
  68. package/samples/package-json-config/src/custom-generated/input-utils.ts +60 -0
  69. package/samples/package-json-config/src/custom-generated/input.ts +42 -0
  70. package/src/apify-schema-tools.ts +302 -0
  71. package/src/apify.ts +124 -0
  72. package/src/configuration.ts +110 -0
  73. package/src/filesystem.ts +18 -0
  74. package/src/json-schemas.ts +252 -0
  75. package/src/typescript.ts +381 -0
  76. package/test/apify-schema-tools.test.ts +2064 -0
  77. package/test/apify.test.ts +28 -0
  78. package/test/common.ts +19 -0
  79. package/test/configuration.test.ts +642 -0
  80. package/test/json-schemas.test.ts +587 -0
  81. package/test/typescript.test.ts +817 -0
  82. package/tsconfig.json +18 -0
  83. package/update-samples.sh +27 -0
@@ -0,0 +1,587 @@
1
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+
5
+ import { writeFile } from "../src/filesystem.js";
6
+ import {
7
+ type ObjectSchema,
8
+ compareDescriptions,
9
+ compareSchemas,
10
+ filterValidSchemaProperties,
11
+ mergeObjectSchemas,
12
+ readJsonSchema,
13
+ readJsonSchemaField,
14
+ writeSchemaToField,
15
+ } from "../src/json-schemas.js";
16
+ import { cleanupTestDirectory, getTestDir, setupTestDirectory } from "./common.js";
17
+
18
+ const TEST_DIR = getTestDir("json-schemas");
19
+
20
+ describe("JSON schemas utilities", () => {
21
+ afterEach(() => {
22
+ cleanupTestDirectory(TEST_DIR);
23
+ });
24
+
25
+ describe("readJsonSchemaField", () => {
26
+ it("should read a specific field from a JSON schema file when field is an object schema", () => {
27
+ setupTestDirectory(TEST_DIR);
28
+ const filePath = join(TEST_DIR, "wrapper-schema.json");
29
+ const wrapperSchema = {
30
+ type: "object",
31
+ title: "Wrapper Schema",
32
+ inputSchema: {
33
+ type: "object",
34
+ properties: {
35
+ name: { type: "string" },
36
+ age: { type: "number" },
37
+ },
38
+ required: ["name"],
39
+ },
40
+ outputSchema: {
41
+ type: "object",
42
+ properties: {
43
+ result: { type: "string" },
44
+ },
45
+ },
46
+ };
47
+
48
+ writeFile(filePath, JSON.stringify(wrapperSchema));
49
+
50
+ const inputSchema = readJsonSchemaField(filePath, "inputSchema");
51
+
52
+ expect(inputSchema).toEqual({
53
+ type: "object",
54
+ properties: {
55
+ name: { type: "string" },
56
+ age: { type: "number" },
57
+ },
58
+ required: ["name"],
59
+ });
60
+ });
61
+
62
+ it("should read another field from the same schema file", () => {
63
+ setupTestDirectory(TEST_DIR);
64
+ const filePath = join(TEST_DIR, "wrapper-schema.json");
65
+ const wrapperSchema = {
66
+ type: "object",
67
+ title: "Wrapper Schema",
68
+ inputSchema: {
69
+ type: "object",
70
+ properties: {
71
+ name: { type: "string" },
72
+ age: { type: "number" },
73
+ },
74
+ required: ["name"],
75
+ },
76
+ outputSchema: {
77
+ type: "object",
78
+ properties: {
79
+ result: { type: "string" },
80
+ },
81
+ },
82
+ };
83
+
84
+ writeFile(filePath, JSON.stringify(wrapperSchema));
85
+
86
+ const outputSchema = readJsonSchemaField(filePath, "outputSchema");
87
+
88
+ expect(outputSchema).toEqual({
89
+ type: "object",
90
+ properties: {
91
+ result: { type: "string" },
92
+ },
93
+ });
94
+ });
95
+
96
+ it("should throw error when field does not exist", () => {
97
+ setupTestDirectory(TEST_DIR);
98
+ const filePath = join(TEST_DIR, "schema-without-field.json");
99
+ const schema = {
100
+ type: "object",
101
+ properties: {
102
+ name: { type: "string" },
103
+ },
104
+ };
105
+
106
+ writeFile(filePath, JSON.stringify(schema));
107
+
108
+ expect(() => {
109
+ readJsonSchemaField(filePath, "nonExistentField");
110
+ }).toThrow(`Field "nonExistentField" not found in schema "${filePath}"`);
111
+ });
112
+
113
+ it("should throw error when field is not an object schema", () => {
114
+ setupTestDirectory(TEST_DIR);
115
+ const filePath = join(TEST_DIR, "schema-with-non-object-field.json");
116
+ const schema = {
117
+ type: "object",
118
+ properties: {
119
+ name: { type: "string" },
120
+ },
121
+ stringField: "not an object schema",
122
+ arrayField: ["also", "not", "an", "object", "schema"],
123
+ primitiveField: {
124
+ type: "string", // This is a property schema, not an object schema
125
+ },
126
+ };
127
+
128
+ writeFile(filePath, JSON.stringify(schema));
129
+
130
+ expect(() => {
131
+ readJsonSchemaField(filePath, "stringField");
132
+ }).toThrow(`Schema "${filePath} > stringField" is not an object schema`);
133
+
134
+ expect(() => {
135
+ readJsonSchemaField(filePath, "arrayField");
136
+ }).toThrow(`Schema "${filePath} > arrayField" is not an object schema`);
137
+
138
+ expect(() => {
139
+ readJsonSchemaField(filePath, "primitiveField");
140
+ }).toThrow(`Schema "${filePath} > primitiveField" is not an object schema`);
141
+ });
142
+
143
+ it("should successfully read nested object schemas", () => {
144
+ setupTestDirectory(TEST_DIR);
145
+ const filePath = join(TEST_DIR, "complex-schema.json");
146
+ const complexSchema = {
147
+ type: "object",
148
+ title: "Complex Schema",
149
+ nestedSchema: {
150
+ type: "object",
151
+ properties: {
152
+ profile: {
153
+ type: "object",
154
+ properties: {
155
+ name: { type: "string" },
156
+ email: { type: "string" },
157
+ },
158
+ required: ["name"],
159
+ },
160
+ preferences: {
161
+ type: "object",
162
+ properties: {
163
+ theme: { type: "string" },
164
+ notifications: { type: "boolean" },
165
+ },
166
+ },
167
+ },
168
+ required: ["profile"],
169
+ },
170
+ };
171
+
172
+ writeFile(filePath, JSON.stringify(complexSchema));
173
+
174
+ const nestedSchema = readJsonSchemaField(filePath, "nestedSchema");
175
+
176
+ expect(nestedSchema).toEqual({
177
+ type: "object",
178
+ properties: {
179
+ profile: {
180
+ type: "object",
181
+ properties: {
182
+ name: { type: "string" },
183
+ email: { type: "string" },
184
+ },
185
+ required: ["name"],
186
+ },
187
+ preferences: {
188
+ type: "object",
189
+ properties: {
190
+ theme: { type: "string" },
191
+ notifications: { type: "boolean" },
192
+ },
193
+ },
194
+ },
195
+ required: ["profile"],
196
+ });
197
+ });
198
+ });
199
+
200
+ describe("writeSchemaToField", () => {
201
+ it("should write a schema to a specific field in an existing JSON schema file", () => {
202
+ setupTestDirectory(TEST_DIR);
203
+ const filePath = join(TEST_DIR, "test-schema.json");
204
+ const wrapperSchema = {
205
+ fields: {
206
+ name: { type: "string" },
207
+ age: { type: "number" },
208
+ },
209
+ };
210
+
211
+ writeFile(filePath, JSON.stringify(wrapperSchema));
212
+
213
+ const schemaToWrite: ObjectSchema = {
214
+ type: "object",
215
+ properties: {
216
+ name: { type: "string" },
217
+ age: { type: "number" },
218
+ },
219
+ };
220
+
221
+ writeSchemaToField(filePath, schemaToWrite, "fields");
222
+
223
+ const updatedSchema = readJsonSchema(filePath);
224
+
225
+ expect(updatedSchema).toEqual({
226
+ fields: {
227
+ type: "object",
228
+ properties: {
229
+ name: { type: "string" },
230
+ age: { type: "number" },
231
+ },
232
+ },
233
+ });
234
+ });
235
+ });
236
+
237
+ describe("filterValidSchemaProperties", () => {
238
+ it("should filter valid keys", () => {
239
+ const schema: ObjectSchema = {
240
+ type: "object",
241
+ properties: {
242
+ name: { type: "string", default: "John Doe" },
243
+ age: { type: "integer", prefill: 30 },
244
+ address: { type: "string", required: true },
245
+ },
246
+ invalidField: "any",
247
+ };
248
+
249
+ const validRootKeys = ["type", "properties"];
250
+ const validPropertyKeys = ["type"];
251
+ const validPropertyTypes = ["string", "integer", "array"];
252
+ const validPropertyKeysByType: Record<string, string[]> = {
253
+ string: ["default", "prefill"],
254
+ };
255
+
256
+ const filteredSchema = filterValidSchemaProperties(
257
+ schema,
258
+ validRootKeys,
259
+ validPropertyKeys,
260
+ validPropertyTypes,
261
+ validPropertyKeysByType,
262
+ );
263
+
264
+ expect(filteredSchema).toEqual({
265
+ type: "object",
266
+ properties: {
267
+ name: { type: "string", default: "John Doe" },
268
+ age: { type: "integer" },
269
+ address: { type: "string" },
270
+ },
271
+ });
272
+ });
273
+
274
+ it("should throw on invalid property type", () => {
275
+ const validPropertyTypes = ["string", "boolean", "integer", "object", "array"];
276
+
277
+ expect(() =>
278
+ filterValidSchemaProperties(
279
+ {
280
+ type: "object",
281
+ properties: {
282
+ string: { type: "string" },
283
+ boolean: { type: "boolean" },
284
+ integer: { type: "integer" },
285
+ object: { type: "object" },
286
+ array: { type: "array" },
287
+ },
288
+ },
289
+ ["type", "properties"],
290
+ [],
291
+ validPropertyTypes,
292
+ {},
293
+ ),
294
+ ).not.toThrow();
295
+
296
+ expect(() =>
297
+ filterValidSchemaProperties(
298
+ {
299
+ type: "object",
300
+ properties: {
301
+ numberProp: { type: "number" },
302
+ },
303
+ },
304
+ ["type", "properties"],
305
+ [],
306
+ validPropertyTypes,
307
+ {},
308
+ ),
309
+ ).toThrow('Invalid property type "number" for property "numberProp" in the input schema');
310
+
311
+ expect(() =>
312
+ filterValidSchemaProperties(
313
+ {
314
+ type: "object",
315
+ properties: {
316
+ nullProp: { type: "null" },
317
+ },
318
+ },
319
+ ["type", "properties"],
320
+ [],
321
+ validPropertyTypes,
322
+ {},
323
+ ),
324
+ ).toThrow('Invalid property type "null" for property "nullProp" in the input schema');
325
+ });
326
+
327
+ it("should throw on multiple types (unsupported)", () => {
328
+ expect(() =>
329
+ filterValidSchemaProperties(
330
+ {
331
+ type: "object",
332
+ properties: {
333
+ multiType: { type: ["string", "integer"] },
334
+ },
335
+ },
336
+ ["type", "properties"],
337
+ [],
338
+ ["string", "integer"],
339
+ {},
340
+ ),
341
+ ).toThrow(
342
+ 'Property "multiType" has an invalid type "string,integer" in the input schema: non-string types are not supported',
343
+ );
344
+ });
345
+
346
+ it("should throw on missing type", () => {
347
+ expect(() =>
348
+ filterValidSchemaProperties(
349
+ {
350
+ type: "object",
351
+ properties: {
352
+ missingType: {},
353
+ },
354
+ },
355
+ ["type", "properties"],
356
+ [],
357
+ ["string", "integer"],
358
+ {},
359
+ ),
360
+ ).toThrow('Property "missingType" is missing a type in the input schema');
361
+ });
362
+ });
363
+
364
+ describe("mergeObjectSchemas", () => {
365
+ it("should merge two object schemas", () => {
366
+ const baseSchema: ObjectSchema = {
367
+ type: "object",
368
+ properties: {
369
+ name: { type: "string" },
370
+ phone: { type: "integer" },
371
+ },
372
+ };
373
+
374
+ const additionalSchema: ObjectSchema = {
375
+ type: "object",
376
+ properties: {
377
+ phone: { type: "string" },
378
+ address: { type: "string" },
379
+ },
380
+ };
381
+
382
+ const deepMerge = false;
383
+
384
+ const mergedSchema = mergeObjectSchemas(baseSchema, additionalSchema, deepMerge);
385
+
386
+ expect(mergedSchema).toEqual({
387
+ type: "object",
388
+ properties: {
389
+ name: { type: "string" },
390
+ phone: { type: "string" }, // phone type changed to string
391
+ address: { type: "string" },
392
+ },
393
+ });
394
+ });
395
+
396
+ it("should deep merge two object schemas", () => {
397
+ const baseSchema: ObjectSchema = {
398
+ type: "object",
399
+ properties: {
400
+ name: { type: "string" },
401
+ phone: { type: "integer" },
402
+ address: {
403
+ type: "object",
404
+ properties: {
405
+ street: { type: "string" },
406
+ city: { type: "string" },
407
+ },
408
+ },
409
+ },
410
+ };
411
+
412
+ const additionalSchema: ObjectSchema = {
413
+ type: "object",
414
+ properties: {
415
+ phone: { type: "string" }, // changing phone type
416
+ address: {
417
+ type: "object",
418
+ properties: {
419
+ city: { type: "string", default: "Unknown City" }, // adding default value
420
+ country: { type: "string" },
421
+ },
422
+ },
423
+ },
424
+ };
425
+
426
+ const deepMerge = true;
427
+
428
+ const mergedSchema = mergeObjectSchemas(baseSchema, additionalSchema, deepMerge);
429
+
430
+ expect(mergedSchema).toEqual({
431
+ type: "object",
432
+ properties: {
433
+ name: { type: "string" },
434
+ phone: { type: "string" }, // phone type changed to string
435
+ address: {
436
+ type: "object",
437
+ properties: {
438
+ street: { type: "string" }, // retained from base schema
439
+ city: { type: "string", default: "Unknown City" }, // default value added
440
+ country: { type: "string" }, // new property added
441
+ },
442
+ },
443
+ },
444
+ });
445
+ });
446
+ });
447
+
448
+ describe("compareJsonSchemas", () => {
449
+ it("returns true for identical schemas", () => {
450
+ const schema1: ObjectSchema = {
451
+ type: "object",
452
+ properties: {
453
+ name: { type: "string" },
454
+ age: { type: "integer" },
455
+ },
456
+ };
457
+
458
+ const schema2: ObjectSchema = {
459
+ type: "object",
460
+ properties: {
461
+ name: { type: "string" },
462
+ age: { type: "integer" },
463
+ },
464
+ };
465
+
466
+ expect(compareSchemas(schema1, schema2)).toBe(true);
467
+ });
468
+
469
+ it("returns false for different schemas", () => {
470
+ const schema1: ObjectSchema = {
471
+ type: "object",
472
+ properties: {
473
+ name: { type: "string" },
474
+ age: { type: "integer" },
475
+ },
476
+ };
477
+
478
+ const schema2: ObjectSchema = {
479
+ type: "object",
480
+ properties: {
481
+ name: { type: "string" },
482
+ age: { type: "number" }, // different type
483
+ },
484
+ };
485
+
486
+ expect(compareSchemas(schema1, schema2)).toBe(false);
487
+ });
488
+
489
+ it("returns true for schemas with different descriptions, if ignoreDescriptions is true", () => {
490
+ const schema1: ObjectSchema = {
491
+ type: "object",
492
+ title: "Person",
493
+ description: "A person object",
494
+ properties: {
495
+ name: { type: "string", description: "Name of the person" },
496
+ age: { type: "integer", description: "Age of the person" },
497
+ },
498
+ };
499
+
500
+ const schema2: ObjectSchema = {
501
+ type: "object",
502
+ title: "A person", // different title
503
+ description: "A person object with different descriptions", // different description
504
+ properties: {
505
+ name: { type: "string", description: "Full name" }, // different description
506
+ age: { type: "integer", description: "Person's age" }, // different description
507
+ },
508
+ };
509
+
510
+ expect(compareSchemas(schema1, schema2, true)).toBe(true);
511
+ });
512
+ });
513
+
514
+ describe("compareDescriptions", () => {
515
+ it("returns true for identical descriptions", () => {
516
+ const sourceSchema: ObjectSchema = {
517
+ type: "object",
518
+ title: "Person",
519
+ description: "A person object",
520
+ properties: {
521
+ name: { type: "string", description: "Name of the person" },
522
+ age: { type: "integer", description: "Age of the person" },
523
+ },
524
+ };
525
+
526
+ const outputSchema: ObjectSchema = {
527
+ type: "object",
528
+ title: "Person",
529
+ description: "A person object",
530
+ properties: {
531
+ name: { type: "string", description: "Name of the person" },
532
+ age: { type: "integer", description: "Age of the person" },
533
+ },
534
+ };
535
+
536
+ expect(compareDescriptions(sourceSchema, outputSchema)).toBe(true);
537
+ });
538
+
539
+ it("returns true if the descriptions are the same for common properties", () => {
540
+ const sourceSchema: ObjectSchema = {
541
+ type: "object",
542
+ title: "Person",
543
+ description: "A person object",
544
+ properties: {
545
+ name: { type: "string", description: "Name of the person" },
546
+ // age property was added to source
547
+ age: { type: "integer", description: "Age of the person" },
548
+ },
549
+ };
550
+ const outputSchema: ObjectSchema = {
551
+ type: "object",
552
+ title: "Person",
553
+ description: "A person object",
554
+ properties: {
555
+ name: { type: "string", description: "Name of the person" },
556
+ // country property was removed from source
557
+ country: { type: "string", description: "Country of the person" },
558
+ },
559
+ };
560
+ expect(compareDescriptions(sourceSchema, outputSchema)).toBe(true);
561
+ });
562
+
563
+ it("returns false for different descriptions", () => {
564
+ const sourceSchema: ObjectSchema = {
565
+ type: "object",
566
+ title: "Person",
567
+ description: "A person object",
568
+ properties: {
569
+ name: { type: "string", description: "Name of the person" },
570
+ age: { type: "integer", description: "Age of the person" },
571
+ },
572
+ };
573
+
574
+ const outputSchema: ObjectSchema = {
575
+ type: "object",
576
+ title: "Person",
577
+ description: "A different description for a person object",
578
+ properties: {
579
+ name: { type: "string", description: "Full name" }, // different description
580
+ age: { type: "integer", description: "Person's age" }, // different description
581
+ },
582
+ };
583
+
584
+ expect(compareDescriptions(sourceSchema, outputSchema)).toBe(false);
585
+ });
586
+ });
587
+ });