@makehq/forman-schema 1.2.4 → 1.3.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/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/utils.ts
2
+ var FORMAN_VISUAL_TYPES = ["banner", "markdown", "html", "separator"];
2
3
  function noEmpty(text) {
3
4
  return text?.trim() || void 0;
4
5
  }
@@ -8,6 +9,35 @@ function isObject(value) {
8
9
  function isOptionGroup(value) {
9
10
  return "options" in value && Array.isArray(value.options);
10
11
  }
12
+ function containsIMLExpression(value) {
13
+ if (typeof value !== "string") return false;
14
+ return value.indexOf("{{") > -1 && value.indexOf("}}") > -1;
15
+ }
16
+ var API_ENDPOINTS = {
17
+ account: "api://connections/{{kind}}",
18
+ aiagent: "api://ai-agents/v1/agents",
19
+ datastore: "api://data-stores",
20
+ hook: "api://hooks/{{kind}}",
21
+ keychain: "api://keys/{{kind}}",
22
+ udt: "api://data-structures"
23
+ };
24
+ function normalizeFormanFieldType(field) {
25
+ const [type, kind] = field.type.split(":");
26
+ if (!type) return field;
27
+ if (!(type in API_ENDPOINTS)) return field;
28
+ let store = isObject(field.options) ? field.options.store : field.options;
29
+ if (typeof store === "string" || Array.isArray(store)) return field;
30
+ store = API_ENDPOINTS[type];
31
+ store = store.replace("/{{kind}}", kind ? `/${kind}` : "");
32
+ return {
33
+ ...field,
34
+ type,
35
+ options: {
36
+ ...field.options,
37
+ store
38
+ }
39
+ };
40
+ }
11
41
 
12
42
  // src/forman.ts
13
43
  var SchemaConversionError = class extends Error {
@@ -22,17 +52,13 @@ var SchemaConversionError = class extends Error {
22
52
  this.name = "SchemaConversionError";
23
53
  }
24
54
  };
25
- var API_ENDPOINTS = {
26
- CONNECTIONS: "api://connections",
27
- HOOKS: "api://hooks",
28
- KEYS: "api://keys"
29
- };
30
55
  var FORMAN_TYPE_MAP = {
31
56
  account: "number",
32
57
  hook: "number",
33
58
  keychain: "number",
34
59
  datastore: "number",
35
60
  aiagent: "string",
61
+ udt: "number",
36
62
  array: "array",
37
63
  collection: "object",
38
64
  text: "string",
@@ -71,40 +97,6 @@ function validateFormanField(field) {
71
97
  throw new SchemaConversionError(`Unknown field type: ${field.type}`, field);
72
98
  }
73
99
  }
74
- function normalizeFieldType(field) {
75
- const typeHandlers = {
76
- "account:": (type) => ({
77
- ...field,
78
- type: "account",
79
- options: {
80
- ...field.options,
81
- store: `${API_ENDPOINTS.CONNECTIONS}/${type.substring(8)}`
82
- }
83
- }),
84
- "hook:": (type) => ({
85
- ...field,
86
- type: "hook",
87
- options: {
88
- ...field.options,
89
- store: `${API_ENDPOINTS.HOOKS}/${type.substring(5)}`
90
- }
91
- }),
92
- "keychain:": (type) => ({
93
- ...field,
94
- type: "keychain",
95
- options: {
96
- ...field.options,
97
- store: `${API_ENDPOINTS.KEYS}/${type.substring(9)}`
98
- }
99
- })
100
- };
101
- for (const [prefix, handler] of Object.entries(typeHandlers)) {
102
- if (field.type.startsWith(prefix)) {
103
- return handler(field.type);
104
- }
105
- }
106
- return field;
107
- }
108
100
  function appendQueryString(path, domain, tail) {
109
101
  if (path.startsWith("api://")) return path;
110
102
  const queryString = tail.map((part) => `${encodeURIComponent(part)}={{${part}}}`).join("&");
@@ -125,7 +117,7 @@ function createDefaultContext() {
125
117
  }
126
118
  function toJSONSchemaInternal(field, context = createDefaultContext()) {
127
119
  validateFormanField(field);
128
- const normalizedField = normalizeFieldType(field);
120
+ const normalizedField = normalizeFormanFieldType(field);
129
121
  const result = {
130
122
  type: FORMAN_TYPE_MAP[normalizedField.type],
131
123
  title: noEmpty(normalizedField.label),
@@ -143,6 +135,7 @@ function toJSONSchemaInternal(field, context = createDefaultContext()) {
143
135
  case "datastore":
144
136
  case "aiagent":
145
137
  case "file":
138
+ case "folder":
146
139
  return handleSelectType(normalizedField, result, context);
147
140
  default:
148
141
  return handlePrimitiveType(normalizedField, result);
@@ -154,6 +147,9 @@ function handleCollectionType(field, result, context) {
154
147
  required: []
155
148
  });
156
149
  function addField(subField, tail) {
150
+ if (FORMAN_VISUAL_TYPES.includes(subField.type)) {
151
+ return;
152
+ }
157
153
  if (!subField.name) return;
158
154
  if (subField.required) {
159
155
  result.required.push(subField.name);
@@ -302,7 +298,7 @@ function handlePrimitiveType(field, result) {
302
298
  }
303
299
  if (field.validate) {
304
300
  if (field.validate.pattern) {
305
- result.pattern = field.validate.pattern;
301
+ result.pattern = typeof field.validate.pattern === "object" ? field.validate.pattern.regexp : field.validate.pattern;
306
302
  }
307
303
  if (field.validate.min !== void 0) {
308
304
  result.minimum = field.validate.min;
@@ -317,6 +313,471 @@ function handlePrimitiveType(field, result) {
317
313
  return result;
318
314
  }
319
315
 
316
+ // src/validator.ts
317
+ var FORMAN_TYPE_MAP2 = {
318
+ account: "number",
319
+ hook: "number",
320
+ keychain: "number",
321
+ datastore: "number",
322
+ aiagent: "string",
323
+ udt: "number",
324
+ array: "array",
325
+ collection: "object",
326
+ text: "string",
327
+ number: "number",
328
+ boolean: "boolean",
329
+ date: "string",
330
+ json: "string",
331
+ buffer: "string",
332
+ cert: "string",
333
+ color: "string",
334
+ email: "string",
335
+ filename: "string",
336
+ file: "string",
337
+ folder: "string",
338
+ hidden: void 0,
339
+ integer: "number",
340
+ uinteger: "number",
341
+ password: "string",
342
+ path: "string",
343
+ pkey: "string",
344
+ port: "number",
345
+ select: void 0,
346
+ time: "string",
347
+ timestamp: "string",
348
+ timezone: "string",
349
+ upload: "array",
350
+ url: "string",
351
+ uuid: "string",
352
+ any: void 0
353
+ };
354
+ async function validateFormanWithDomainsInternal(domains, options) {
355
+ const errors = [];
356
+ const roots = Object.keys(domains).reduce(
357
+ (acc, domain) => {
358
+ acc[domain] = {
359
+ seenFields: /* @__PURE__ */ new Set(),
360
+ validateFields: (fields, context) => {
361
+ return validateFormanValue(
362
+ domains[domain].values,
363
+ {
364
+ name: domain,
365
+ type: "collection",
366
+ spec: fields
367
+ },
368
+ {
369
+ ...context,
370
+ path: [],
371
+ domain
372
+ }
373
+ );
374
+ }
375
+ };
376
+ return acc;
377
+ },
378
+ {}
379
+ );
380
+ for (const domain of Object.keys(domains)) {
381
+ if (!domains[domain]) continue;
382
+ const result = await validateFormanValue(
383
+ domains[domain].values,
384
+ {
385
+ name: domain,
386
+ type: "collection",
387
+ spec: domains[domain].schema || []
388
+ },
389
+ {
390
+ roots,
391
+ domain,
392
+ path: [],
393
+ tail: [],
394
+ strict: options?.strict === true,
395
+ validateNestedFields: () => {
396
+ throw new Error("Cannot validate nested fields without parent field.");
397
+ },
398
+ resolveRemote: async (path, context) => {
399
+ if (!options?.resolveRemote) {
400
+ throw new Error("Remote resource not supported when resolver is not provided.");
401
+ }
402
+ const data = context.tail.reduce(
403
+ (acc, curr) => {
404
+ acc[curr.name] = curr.value;
405
+ return acc;
406
+ },
407
+ {}
408
+ );
409
+ return await options.resolveRemote(path, data);
410
+ }
411
+ }
412
+ );
413
+ errors.push(...result.errors);
414
+ }
415
+ return {
416
+ valid: errors.length === 0,
417
+ errors
418
+ };
419
+ }
420
+ async function validateFormanValue(value, field, context) {
421
+ if (FORMAN_VISUAL_TYPES.includes(field.type)) {
422
+ return {
423
+ valid: true,
424
+ errors: []
425
+ };
426
+ }
427
+ const normalizedField = normalizeFormanFieldType(field);
428
+ if (normalizedField.required && (value == null || value === "")) {
429
+ return {
430
+ valid: false,
431
+ errors: [
432
+ {
433
+ domain: context.domain,
434
+ path: context.path.join("."),
435
+ message: "Field is mandatory."
436
+ }
437
+ ]
438
+ };
439
+ }
440
+ if (value == null) {
441
+ return {
442
+ valid: true,
443
+ errors: []
444
+ };
445
+ }
446
+ const expectedType = FORMAN_TYPE_MAP2[normalizedField.type];
447
+ let actualType = typeof value;
448
+ if (actualType === "object" && Array.isArray(value)) actualType = "array";
449
+ if (expectedType && expectedType !== actualType) {
450
+ return {
451
+ valid: false,
452
+ errors: [
453
+ {
454
+ domain: context.domain,
455
+ path: context.path.join("."),
456
+ message: `Expected type '${expectedType}', got type '${actualType}'.`
457
+ }
458
+ ]
459
+ };
460
+ }
461
+ switch (normalizedField.type) {
462
+ case "collection":
463
+ return handleCollectionType2(value, normalizedField, context);
464
+ case "array":
465
+ return handleArrayType2(value, normalizedField, context);
466
+ case "select":
467
+ case "account":
468
+ case "hook":
469
+ case "keychain":
470
+ case "datastore":
471
+ case "aiagent":
472
+ case "file":
473
+ case "folder":
474
+ return handleSelectType2(value, normalizedField, context);
475
+ default:
476
+ return handlePrimitiveType2(value, normalizedField, context);
477
+ }
478
+ }
479
+ async function handleCollectionType2(value, field, context) {
480
+ const errors = [];
481
+ const seen = context.path.length === 0 ? context.roots[context.domain].seenFields : /* @__PURE__ */ new Set();
482
+ const path = context.path;
483
+ if (Array.isArray(field.spec)) {
484
+ for (const subField of field.spec) {
485
+ if (FORMAN_VISUAL_TYPES.includes(subField.type)) {
486
+ continue;
487
+ }
488
+ if (!subField.name) {
489
+ errors.push({
490
+ domain: context.domain,
491
+ path: context.path.join("."),
492
+ message: "Object contains field with unknown name."
493
+ });
494
+ continue;
495
+ }
496
+ if (context.strict && !seen.has(subField.name)) seen.add(subField.name);
497
+ const result = await validateFormanValue(value[subField.name], subField, {
498
+ ...context,
499
+ path: [...path, subField.name],
500
+ validateNestedFields: async (fields, context2) => {
501
+ for (const subField2 of fields) {
502
+ if (FORMAN_VISUAL_TYPES.includes(subField2.type)) {
503
+ continue;
504
+ }
505
+ if (!subField2.name) {
506
+ errors.push({
507
+ domain: context2.domain,
508
+ path: context2.path.join("."),
509
+ message: "Object contains field with unknown name."
510
+ });
511
+ continue;
512
+ }
513
+ if (context2.strict && !seen.has(subField2.name)) seen.add(subField2.name);
514
+ const result2 = await validateFormanValue(value[subField2.name], subField2, {
515
+ ...context2,
516
+ path: [...path, subField2.name]
517
+ });
518
+ errors.push(...result2.errors);
519
+ }
520
+ }
521
+ });
522
+ errors.push(...result.errors);
523
+ }
524
+ }
525
+ if (context.strict) {
526
+ for (const key of Object.keys(value)) {
527
+ if (!seen.has(key)) {
528
+ seen.add(key);
529
+ errors.push({
530
+ domain: context.domain,
531
+ path: context.path.join("."),
532
+ message: `Unknown field '${key}'.`
533
+ });
534
+ }
535
+ }
536
+ }
537
+ return {
538
+ valid: errors.length === 0,
539
+ errors
540
+ };
541
+ }
542
+ async function handleArrayType2(value, field, context) {
543
+ const errors = [];
544
+ if (field.spec) {
545
+ for (const [index, item] of value.entries()) {
546
+ const result = await validateFormanValue(
547
+ item,
548
+ Array.isArray(field.spec) ? { name: index.toString(), type: "collection", spec: field.spec } : Object.assign({}, field.spec, { name: index.toString() }),
549
+ { ...context, path: [...context.path, index.toString()] }
550
+ );
551
+ errors.push(...result.errors);
552
+ }
553
+ }
554
+ if (field.validate) {
555
+ if (field.validate.minItems !== void 0 && value.length < field.validate.minItems) {
556
+ errors.push({
557
+ domain: context.domain,
558
+ path: context.path.join("."),
559
+ message: `Array has less than ${field.validate.minItems} items.`
560
+ });
561
+ }
562
+ if (field.validate.maxItems !== void 0 && value.length > field.validate.maxItems) {
563
+ errors.push({
564
+ domain: context.domain,
565
+ path: context.path.join("."),
566
+ message: `Array has more than ${field.validate.maxItems} items.`
567
+ });
568
+ }
569
+ }
570
+ return {
571
+ valid: errors.length === 0,
572
+ errors
573
+ };
574
+ }
575
+ async function handleSelectType2(value, field, context) {
576
+ const errors = [];
577
+ let optionsOrGroups = isObject(field.options) ? field.options.store : field.options;
578
+ let nested = isObject(field.options) ? field.options.nested : void 0;
579
+ if (typeof optionsOrGroups === "string") {
580
+ try {
581
+ optionsOrGroups = await context.resolveRemote(optionsOrGroups, context);
582
+ } catch (error) {
583
+ return {
584
+ valid: false,
585
+ errors: [
586
+ ...errors,
587
+ {
588
+ domain: context.domain,
589
+ path: context.path.join("."),
590
+ message: `Failed to resolve remote resource ${optionsOrGroups}: ${error}`
591
+ }
592
+ ]
593
+ };
594
+ }
595
+ }
596
+ if (field.multiple) {
597
+ if (!Array.isArray(value)) {
598
+ return {
599
+ valid: false,
600
+ errors: [
601
+ ...errors,
602
+ {
603
+ domain: context.domain,
604
+ path: context.path.join("."),
605
+ message: `Value is not an array.`
606
+ }
607
+ ]
608
+ };
609
+ }
610
+ for (const singleValue of value) {
611
+ const found = field.grouped ? optionsOrGroups.some(
612
+ (group) => group.options.some((option) => option.value === singleValue)
613
+ ) : optionsOrGroups.some((option) => option.value === singleValue);
614
+ if (!found) {
615
+ errors.push({
616
+ domain: context.domain,
617
+ path: context.path.join("."),
618
+ message: `Value '${singleValue}' not found in options.`
619
+ });
620
+ }
621
+ }
622
+ if (field.validate) {
623
+ if (field.validate.minItems !== void 0 && value.length < field.validate.minItems) {
624
+ errors.push({
625
+ domain: context.domain,
626
+ path: context.path.join("."),
627
+ message: `Selected less than ${field.validate.minItems} items.`
628
+ });
629
+ }
630
+ if (field.validate.maxItems !== void 0 && value.length > field.validate.maxItems) {
631
+ errors.push({
632
+ domain: context.domain,
633
+ path: context.path.join("."),
634
+ message: `Selected more than ${field.validate.maxItems} items.`
635
+ });
636
+ }
637
+ }
638
+ } else {
639
+ const item = field.grouped ? optionsOrGroups.find((group) => group.options.some((option) => option.value === value))?.options.find((option) => option.value === value) : optionsOrGroups.find((option) => option.value === value);
640
+ if (!item) {
641
+ return {
642
+ valid: false,
643
+ errors: [
644
+ ...errors,
645
+ {
646
+ domain: context.domain,
647
+ path: context.path.join("."),
648
+ message: `Value '${value}' not found in options.`
649
+ }
650
+ ]
651
+ };
652
+ }
653
+ if (item.nested) nested = item.nested;
654
+ }
655
+ if (nested) {
656
+ const result = await handleNestedFields(nested, value, field, context);
657
+ errors.push(...result.errors);
658
+ }
659
+ return {
660
+ valid: errors.length === 0,
661
+ errors
662
+ };
663
+ }
664
+ async function handleNestedFields(nested, value, field, context) {
665
+ const errors = [];
666
+ let store = isObject(nested) ? nested.store : nested;
667
+ const domain = isObject(nested) && nested.domain ? nested.domain : void 0;
668
+ context = {
669
+ ...context,
670
+ tail: field.name ? [...context.tail, { name: field.name, value }] : context.tail
671
+ };
672
+ if (typeof store === "string") {
673
+ try {
674
+ store = await context.resolveRemote(store, context);
675
+ } catch (error) {
676
+ return {
677
+ valid: false,
678
+ errors: [
679
+ ...errors,
680
+ {
681
+ domain: context.domain,
682
+ path: context.path.join("."),
683
+ message: `Failed to resolve remote resource ${store}: ${error}`
684
+ }
685
+ ]
686
+ };
687
+ }
688
+ }
689
+ if (store && domain && domain !== context.domain) {
690
+ if (!context.roots[domain]) {
691
+ errors.push({
692
+ domain: context.domain,
693
+ path: context.path.join("."),
694
+ message: `Unable to process nested fields: Domain '${domain}' not found.`
695
+ });
696
+ } else {
697
+ const result = await context.roots[domain].validateFields(store, context);
698
+ errors.push(...result.errors);
699
+ }
700
+ } else if (store) {
701
+ await context.validateNestedFields(store, context);
702
+ }
703
+ return {
704
+ valid: errors.length === 0,
705
+ errors
706
+ };
707
+ }
708
+ async function handlePrimitiveType2(value, field, context) {
709
+ const errors = [];
710
+ if (containsIMLExpression(value)) {
711
+ return {
712
+ valid: true,
713
+ errors
714
+ };
715
+ }
716
+ if (errors.length > 0) {
717
+ return {
718
+ valid: false,
719
+ errors
720
+ };
721
+ }
722
+ if (field.validate) {
723
+ if (typeof value === "string") {
724
+ if (field.validate.pattern && !new RegExp(
725
+ typeof field.validate.pattern === "object" ? field.validate.pattern.regexp : field.validate.pattern
726
+ ).test(value)) {
727
+ errors.push({
728
+ domain: context.domain,
729
+ path: context.path.join("."),
730
+ message: `Value doesn't match the pattern: ${typeof field.validate.pattern === "object" ? field.validate.pattern.regexp : field.validate.pattern}`
731
+ });
732
+ }
733
+ if (field.validate.min !== void 0 && value.length < field.validate.min) {
734
+ errors.push({
735
+ domain: context.domain,
736
+ path: context.path.join("."),
737
+ message: `Value must be at least ${field.validate.min} characters long.`
738
+ });
739
+ }
740
+ if (field.validate.max !== void 0 && value.length > field.validate.max) {
741
+ errors.push({
742
+ domain: context.domain,
743
+ path: context.path.join("."),
744
+ message: `Value exceeded maximum length of ${field.validate.max} characters.`
745
+ });
746
+ }
747
+ if (field.validate.enum && !field.validate.enum.includes(value)) {
748
+ errors.push({
749
+ domain: context.domain,
750
+ path: context.path.join("."),
751
+ message: "Value must be one of the following: " + field.validate.enum.join(", ")
752
+ });
753
+ }
754
+ } else if (typeof value === "number") {
755
+ if (field.validate.min !== void 0 && value < field.validate.min) {
756
+ errors.push({
757
+ domain: context.domain,
758
+ path: context.path.join("."),
759
+ message: `Value is too small. Minimum value is ${field.validate.min}.`
760
+ });
761
+ }
762
+ if (field.validate.max !== void 0 && value > field.validate.max) {
763
+ errors.push({
764
+ domain: context.domain,
765
+ path: context.path.join("."),
766
+ message: `Value is too big. Maximum value is ${field.validate.max}.`
767
+ });
768
+ }
769
+ }
770
+ }
771
+ if (field.nested) {
772
+ const result = await handleNestedFields(field.nested, value, field, context);
773
+ errors.push(...result.errors);
774
+ }
775
+ return {
776
+ valid: errors.length === 0,
777
+ errors
778
+ };
779
+ }
780
+
320
781
  // src/json.ts
321
782
  var JSON_PRIMITIVE_TYPE_MAP = {
322
783
  string: "text",
@@ -406,7 +867,15 @@ function toFormanSchema(field) {
406
867
  function toJSONSchema(field) {
407
868
  return toJSONSchemaInternal(field);
408
869
  }
870
+ function validateFormanWithDomains(domains, options) {
871
+ return validateFormanWithDomainsInternal(domains, options);
872
+ }
873
+ function validateForman(values, schema, options) {
874
+ return validateFormanWithDomains({ default: { values, schema } }, options);
875
+ }
409
876
  export {
410
877
  toFormanSchema,
411
- toJSONSchema
878
+ toJSONSchema,
879
+ validateForman,
880
+ validateFormanWithDomains
412
881
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makehq/forman-schema",
3
- "version": "1.2.4",
3
+ "version": "1.3.0",
4
4
  "description": "Forman Schema Tools",
5
5
  "license": "MIT",
6
6
  "author": "Make",
@@ -28,7 +28,7 @@
28
28
  "dist"
29
29
  ],
30
30
  "scripts": {
31
- "test": "jest --runInBand --forceExit --verbose false",
31
+ "test": "jest --coverage --runInBand --forceExit --verbose false",
32
32
  "build": "tsup",
33
33
  "build:version": "node scripts/build-version.mjs",
34
34
  "format": "npx prettier . --write",