@forgehive/schema 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,8 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Schema = void 0;
3
+ exports.Schema = exports.z = void 0;
4
4
  const zod_1 = require("zod");
5
- // Static methods for type definitions
5
+ Object.defineProperty(exports, "z", { enumerable: true, get: function () { return zod_1.z; } });
6
+ /**
7
+ * A thin wrapper around a zod object schema. Fields are created with the static
8
+ * `Schema.*` helpers so call sites stay independent of the underlying validation
9
+ * library; the wrapper owns validation plus JSON Schema serialization
10
+ * (`describe`) and rehydration (`from`).
11
+ */
6
12
  class Schema {
7
13
  constructor(fields) {
8
14
  this.schema = zod_1.z.object(fields);
@@ -14,6 +20,13 @@ class Schema {
14
20
  static string() {
15
21
  return zod_1.z.string();
16
22
  }
23
+ /**
24
+ * Creates a number schema
25
+ * @returns A number schema
26
+ */
27
+ static number() {
28
+ return zod_1.z.number();
29
+ }
17
30
  /**
18
31
  * Creates a boolean schema
19
32
  * @returns A boolean schema
@@ -22,18 +35,49 @@ class Schema {
22
35
  return zod_1.z.boolean();
23
36
  }
24
37
  /**
25
- * Creates a number schema
26
- * @returns A number schema
38
+ * Creates an ISO 8601 date-time schema. The runtime value is a string
39
+ * (e.g. "2020-01-01T00:00:00Z"), which is natively representable in JSON Schema.
40
+ * @returns An ISO date-time schema
27
41
  */
28
- static number() {
29
- return zod_1.z.number();
42
+ static date() {
43
+ return zod_1.z.iso.datetime();
30
44
  }
31
45
  /**
32
- * Creates a date schema
33
- * @returns A date schema
46
+ * Creates an email string schema (serializes to JSON Schema `format: "email"`).
47
+ * @returns An email schema
34
48
  */
35
- static date() {
36
- return zod_1.z.date();
49
+ static email() {
50
+ return zod_1.z.email();
51
+ }
52
+ /**
53
+ * Creates a UUID string schema (serializes to JSON Schema `format: "uuid"`).
54
+ * @returns A UUID schema
55
+ */
56
+ static uuid() {
57
+ return zod_1.z.uuid();
58
+ }
59
+ /**
60
+ * Creates a URL string schema (serializes to JSON Schema `format: "uri"`).
61
+ * @returns A URL schema
62
+ */
63
+ static url() {
64
+ return zod_1.z.url();
65
+ }
66
+ /**
67
+ * Creates an array schema
68
+ * @param type The type of items in the array
69
+ * @returns An array schema
70
+ */
71
+ static array(type) {
72
+ return zod_1.z.array(type);
73
+ }
74
+ /**
75
+ * Creates a nested object schema
76
+ * @param fields The fields of the object
77
+ * @returns An object schema
78
+ */
79
+ static object(fields) {
80
+ return zod_1.z.object(fields);
37
81
  }
38
82
  /**
39
83
  * Creates a record schema with string keys and string values
@@ -63,14 +107,6 @@ class Schema {
63
107
  static mixedRecord() {
64
108
  return zod_1.z.record(zod_1.z.string(), zod_1.z.union([zod_1.z.string(), zod_1.z.number(), zod_1.z.boolean()]));
65
109
  }
66
- /**
67
- * Creates an array schema
68
- * @param type The type of items in the array
69
- * @returns An array schema
70
- */
71
- static array(type) {
72
- return zod_1.z.array(type);
73
- }
74
110
  /**
75
111
  * Infers the TypeScript type from a Schema instance
76
112
  * @template S The Schema type
@@ -81,94 +117,21 @@ class Schema {
81
117
  return {};
82
118
  }
83
119
  /**
84
- * Creates a Schema instance from a description object
85
- * @param description Object describing the schema structure with type information
120
+ * Creates a Schema instance from a JSON Schema description.
121
+ *
122
+ * Note: this relies on zod's `fromJSONSchema`, which zod considers
123
+ * semi-experimental. Round-trips of schemas produced by `describe()` are
124
+ * covered by the package tests.
125
+ *
126
+ * @param description A JSON Schema object describing an object schema
86
127
  * @returns A new Schema instance
87
128
  */
88
129
  static from(description) {
89
- const fields = {};
90
- for (const [key, field] of Object.entries(description)) {
91
- const fieldType = field.type;
92
- let fieldSchema;
93
- switch (fieldType) {
94
- case 'string': {
95
- let stringSchema = Schema.string();
96
- if (field.validations) {
97
- const validations = field.validations;
98
- if (validations.email) {
99
- stringSchema = stringSchema.email();
100
- }
101
- if (validations.minLength !== undefined) {
102
- stringSchema = stringSchema.min(validations.minLength);
103
- }
104
- if (validations.maxLength !== undefined) {
105
- stringSchema = stringSchema.max(validations.maxLength);
106
- }
107
- if (validations.regex !== undefined) {
108
- stringSchema = stringSchema.regex(new RegExp(validations.regex));
109
- }
110
- }
111
- fieldSchema = stringSchema;
112
- break;
113
- }
114
- case 'boolean':
115
- fieldSchema = Schema.boolean();
116
- break;
117
- case 'number': {
118
- let numberSchema = Schema.number();
119
- if (field.validations) {
120
- const validations = field.validations;
121
- if (validations.min !== undefined) {
122
- numberSchema = numberSchema.min(validations.min);
123
- }
124
- if (validations.max !== undefined) {
125
- numberSchema = numberSchema.max(validations.max);
126
- }
127
- }
128
- fieldSchema = numberSchema;
129
- break;
130
- }
131
- case 'date':
132
- fieldSchema = Schema.date();
133
- break;
134
- case 'stringRecord':
135
- fieldSchema = Schema.stringRecord();
136
- break;
137
- case 'numberRecord':
138
- fieldSchema = Schema.numberRecord();
139
- break;
140
- case 'booleanRecord':
141
- fieldSchema = Schema.booleanRecord();
142
- break;
143
- case 'mixedRecord':
144
- fieldSchema = Schema.mixedRecord();
145
- break;
146
- case 'array': {
147
- const arrayField = field;
148
- switch (arrayField.items.type) {
149
- case 'string':
150
- fieldSchema = Schema.array(Schema.string());
151
- break;
152
- case 'boolean':
153
- fieldSchema = Schema.array(Schema.boolean());
154
- break;
155
- case 'number':
156
- fieldSchema = Schema.array(Schema.number());
157
- break;
158
- case 'date':
159
- fieldSchema = Schema.array(Schema.date());
160
- break;
161
- default:
162
- throw new Error(`Unsupported array item type: ${arrayField.items.type}`);
163
- }
164
- break;
165
- }
166
- default:
167
- throw new Error(`Unsupported type: ${fieldType}`);
168
- }
169
- fields[key] = field.optional ? fieldSchema.optional() : fieldSchema;
130
+ const zodSchema = zod_1.z.fromJSONSchema(description);
131
+ if (!(zodSchema instanceof zod_1.z.ZodObject)) {
132
+ throw new Error('Schema.from expects a JSON Schema that describes an object');
170
133
  }
171
- return new Schema(fields);
134
+ return new Schema(zodSchema.shape);
172
135
  }
173
136
  /**
174
137
  * Validates the provided data against the schema
@@ -197,88 +160,18 @@ class Schema {
197
160
  return this.schema.safeParse(data);
198
161
  }
199
162
  /**
200
- * Describes the schema structure and allowed types
201
- * @returns An object describing the schema structure with type information
163
+ * Serializes the schema to JSON Schema (draft 2020-12).
164
+ * @returns A JSON Schema object describing the schema structure
202
165
  */
203
166
  describe() {
204
- const shape = this.schema.shape;
205
- const description = {};
206
- for (const [key, value] of Object.entries(shape)) {
207
- const isOptional = value instanceof zod_1.z.ZodOptional;
208
- const baseValue = isOptional ? value.unwrap() : value;
209
- if (baseValue instanceof zod_1.z.ZodString) {
210
- const validations = {};
211
- if (baseValue._def.checks) {
212
- for (const check of baseValue._def.checks) {
213
- if (check.kind === 'email') {
214
- validations.email = true;
215
- }
216
- else if (check.kind === 'min') {
217
- validations.minLength = check.value;
218
- }
219
- else if (check.kind === 'max') {
220
- validations.maxLength = check.value;
221
- }
222
- else if (check.kind === 'regex') {
223
- validations.regex = check.regex.toString().replace(/^\/|\/$/g, '').replace(/\\\//g, '/');
224
- }
225
- }
226
- }
227
- description[key] = Object.assign(Object.assign({ type: 'string' }, (isOptional && { optional: true })), (Object.keys(validations).length > 0 && { validations }));
228
- }
229
- else if (baseValue instanceof zod_1.z.ZodBoolean) {
230
- description[key] = Object.assign({ type: 'boolean' }, (isOptional && { optional: true }));
231
- }
232
- else if (baseValue instanceof zod_1.z.ZodNumber) {
233
- const validations = {};
234
- if (baseValue._def.checks) {
235
- for (const check of baseValue._def.checks) {
236
- if (check.kind === 'min') {
237
- validations.min = check.value;
238
- }
239
- else if (check.kind === 'max') {
240
- validations.max = check.value;
241
- }
242
- }
243
- }
244
- description[key] = Object.assign(Object.assign({ type: 'number' }, (isOptional && { optional: true })), (Object.keys(validations).length > 0 && { validations }));
245
- }
246
- else if (baseValue instanceof zod_1.z.ZodDate) {
247
- description[key] = Object.assign({ type: 'date' }, (isOptional && { optional: true }));
248
- }
249
- else if (baseValue instanceof zod_1.z.ZodArray) {
250
- const element = baseValue.element;
251
- if (element instanceof zod_1.z.ZodString) {
252
- description[key] = Object.assign({ type: 'array', items: { type: 'string' } }, (isOptional && { optional: true }));
253
- }
254
- else if (element instanceof zod_1.z.ZodBoolean) {
255
- description[key] = Object.assign({ type: 'array', items: { type: 'boolean' } }, (isOptional && { optional: true }));
256
- }
257
- else if (element instanceof zod_1.z.ZodNumber) {
258
- description[key] = Object.assign({ type: 'array', items: { type: 'number' } }, (isOptional && { optional: true }));
259
- }
260
- else if (element instanceof zod_1.z.ZodDate) {
261
- description[key] = Object.assign({ type: 'array', items: { type: 'date' } }, (isOptional && { optional: true }));
262
- }
263
- }
264
- else if (baseValue instanceof zod_1.z.ZodRecord) {
265
- // Check the value type of the record
266
- const valueType = baseValue._def.valueType;
267
- if (valueType instanceof zod_1.z.ZodString) {
268
- description[key] = Object.assign({ type: 'stringRecord' }, (isOptional && { optional: true }));
269
- }
270
- else if (valueType instanceof zod_1.z.ZodNumber) {
271
- description[key] = Object.assign({ type: 'numberRecord' }, (isOptional && { optional: true }));
272
- }
273
- else if (valueType instanceof zod_1.z.ZodBoolean) {
274
- description[key] = Object.assign({ type: 'booleanRecord' }, (isOptional && { optional: true }));
275
- }
276
- else if (valueType instanceof zod_1.z.ZodUnion) {
277
- description[key] = Object.assign({ type: 'mixedRecord' }, (isOptional && { optional: true }));
278
- }
279
- }
280
- }
281
- return description;
167
+ return zod_1.z.toJSONSchema(this.schema, { target: 'draft-2020-12' });
168
+ }
169
+ /**
170
+ * Returns the underlying Zod schema object
171
+ * @returns The Zod schema object
172
+ */
173
+ asZod() {
174
+ return this.schema;
282
175
  }
283
176
  }
284
177
  exports.Schema = Schema;
@@ -3,21 +3,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const index_1 = require("../index");
4
4
  describe('Schema Custom Number Validations', () => {
5
5
  it('should validate number min/max constraints', () => {
6
- const schema = index_1.Schema.from({
7
- age: {
8
- type: 'number',
9
- validations: {
10
- min: 18,
11
- max: 100
12
- }
13
- },
14
- score: {
15
- type: 'number',
16
- validations: {
17
- min: 0,
18
- max: 100
19
- }
20
- }
6
+ const schema = new index_1.Schema({
7
+ age: index_1.Schema.number().min(18).max(100),
8
+ score: index_1.Schema.number().min(0).max(100)
21
9
  });
22
10
  // Valid cases
23
11
  expect(schema.validate({ age: 25, score: 85 })).toBe(true);
@@ -29,35 +17,22 @@ describe('Schema Custom Number Validations', () => {
29
17
  expect(schema.validate({ age: 101, score: 85 })).toBe(false);
30
18
  expect(schema.validate({ age: 25, score: 101 })).toBe(false);
31
19
  });
32
- it('should correctly describe number validations', () => {
33
- const schema = index_1.Schema.from({
34
- age: {
35
- type: 'number',
36
- validations: {
37
- min: 18,
38
- max: 100
39
- }
40
- }
20
+ it('should describe number validations as JSON Schema', () => {
21
+ const schema = new index_1.Schema({
22
+ age: index_1.Schema.number().min(18).max(100)
41
23
  });
42
24
  const description = schema.describe();
43
- expect(description.age).toEqual({
25
+ expect(description.properties?.age).toEqual({
44
26
  type: 'number',
45
- validations: {
46
- min: 18,
47
- max: 100
48
- }
27
+ minimum: 18,
28
+ maximum: 100
49
29
  });
50
30
  });
51
31
  });
52
32
  describe('Schema Custom String Validations', () => {
53
33
  it('should validate string email format', () => {
54
- const schema = index_1.Schema.from({
55
- email: {
56
- type: 'string',
57
- validations: {
58
- email: true
59
- }
60
- }
34
+ const schema = new index_1.Schema({
35
+ email: index_1.Schema.email()
61
36
  });
62
37
  // Valid cases
63
38
  expect(schema.validate({ email: 'user@example.com' })).toBe(true);
@@ -69,14 +44,8 @@ describe('Schema Custom String Validations', () => {
69
44
  expect(schema.validate({ email: '@domain.com' })).toBe(false);
70
45
  });
71
46
  it('should validate string length constraints', () => {
72
- const schema = index_1.Schema.from({
73
- username: {
74
- type: 'string',
75
- validations: {
76
- minLength: 3,
77
- maxLength: 20
78
- }
79
- }
47
+ const schema = new index_1.Schema({
48
+ username: index_1.Schema.string().min(3).max(20)
80
49
  });
81
50
  // Valid cases
82
51
  expect(schema.validate({ username: 'abc' })).toBe(true);
@@ -87,13 +56,8 @@ describe('Schema Custom String Validations', () => {
87
56
  expect(schema.validate({ username: 'a'.repeat(21) })).toBe(false);
88
57
  });
89
58
  it('should validate string regex pattern', () => {
90
- const schema = index_1.Schema.from({
91
- username: {
92
- type: 'string',
93
- validations: {
94
- regex: '^[a-zA-Z0-9_]+$'
95
- }
96
- }
59
+ const schema = new index_1.Schema({
60
+ username: index_1.Schema.string().regex(/^[a-zA-Z0-9_]+$/)
97
61
  });
98
62
  // Valid cases
99
63
  expect(schema.validate({ username: 'john_doe123' })).toBe(true);
@@ -104,54 +68,31 @@ describe('Schema Custom String Validations', () => {
104
68
  expect(schema.validate({ username: 'user@123' })).toBe(false);
105
69
  expect(schema.validate({ username: 'user name' })).toBe(false);
106
70
  });
107
- it('should correctly describe string validations', () => {
108
- const schema = index_1.Schema.from({
109
- email: {
110
- type: 'string',
111
- validations: {
112
- email: true,
113
- minLength: 5,
114
- maxLength: 100
115
- }
116
- }
71
+ it('should describe string length validations as JSON Schema', () => {
72
+ const schema = new index_1.Schema({
73
+ email: index_1.Schema.email().min(5).max(100)
117
74
  });
118
75
  const description = schema.describe();
119
- expect(description.email).toEqual({
76
+ expect(description.properties?.email).toMatchObject({
120
77
  type: 'string',
121
- validations: {
122
- email: true,
123
- minLength: 5,
124
- maxLength: 100
125
- }
78
+ format: 'email',
79
+ minLength: 5,
80
+ maxLength: 100
126
81
  });
127
82
  });
128
- it('should correctly describe string regex validation', () => {
129
- const schema = index_1.Schema.from({
130
- username: {
131
- type: 'string',
132
- validations: {
133
- regex: '^[a-zA-Z0-9_]+$'
134
- }
135
- }
83
+ it('should describe string regex validation as a pattern', () => {
84
+ const schema = new index_1.Schema({
85
+ username: index_1.Schema.string().regex(/^[a-zA-Z0-9_]+$/)
136
86
  });
137
87
  const description = schema.describe();
138
- expect(description.username).toEqual({
88
+ expect(description.properties?.username).toMatchObject({
139
89
  type: 'string',
140
- validations: {
141
- regex: '^[a-zA-Z0-9_]+$'
142
- }
90
+ pattern: '^[a-zA-Z0-9_]+$'
143
91
  });
144
92
  });
145
93
  it('should handle regex with other string validations', () => {
146
- const schema = index_1.Schema.from({
147
- username: {
148
- type: 'string',
149
- validations: {
150
- regex: '^[a-zA-Z0-9_]+$',
151
- minLength: 3,
152
- maxLength: 20
153
- }
154
- }
94
+ const schema = new index_1.Schema({
95
+ username: index_1.Schema.string().regex(/^[a-zA-Z0-9_]+$/).min(3).max(20)
155
96
  });
156
97
  // Valid cases
157
98
  expect(schema.validate({ username: 'john_doe123' })).toBe(true);
@@ -164,13 +105,8 @@ describe('Schema Custom String Validations', () => {
164
105
  expect(schema.validate({ username: 'a'.repeat(21) })).toBe(false);
165
106
  });
166
107
  it('should handle regex patterns containing forward slashes', () => {
167
- const schema = index_1.Schema.from({
168
- path: {
169
- type: 'string',
170
- validations: {
171
- regex: '^/api/v[0-9]+/users/[0-9]+$'
172
- }
173
- }
108
+ const schema = new index_1.Schema({
109
+ path: index_1.Schema.string().regex(/^\/api\/v[0-9]+\/users\/[0-9]+$/)
174
110
  });
175
111
  // Valid cases
176
112
  expect(schema.validate({ path: '/api/v1/users/123' })).toBe(true);
@@ -180,23 +116,16 @@ describe('Schema Custom String Validations', () => {
180
116
  expect(schema.validate({ path: 'api/v1/users/123' })).toBe(false);
181
117
  expect(schema.validate({ path: '/api/v1/users' })).toBe(false);
182
118
  expect(schema.validate({ path: '/api/v1/users/abc' })).toBe(false);
183
- // Verify the description preserves the slashes within the pattern
119
+ // Verify the description preserves the pattern (regex source keeps escaped slashes)
184
120
  const description = schema.describe();
185
- expect(description.path).toEqual({
121
+ expect(description.properties?.path).toMatchObject({
186
122
  type: 'string',
187
- validations: {
188
- regex: '^/api/v[0-9]+/users/[0-9]+$'
189
- }
123
+ pattern: '^\\/api\\/v[0-9]+\\/users\\/[0-9]+$'
190
124
  });
191
125
  });
192
126
  it('should handle round trip of regex patterns containing forward slashes', () => {
193
- const schema = index_1.Schema.from({
194
- path: {
195
- type: 'string',
196
- validations: {
197
- regex: '^/api/v[0-9]+/users/[0-9]+$'
198
- }
199
- }
127
+ const schema = new index_1.Schema({
128
+ path: index_1.Schema.string().regex(/^\/api\/v[0-9]+\/users\/[0-9]+$/)
200
129
  });
201
130
  const description = schema.describe();
202
131
  const cloneSchema = index_1.Schema.from(description);
@@ -208,35 +137,20 @@ describe('Schema Custom String Validations', () => {
208
137
  expect(cloneSchema.validate({ path: 'api/v1/users/123' })).toBe(false);
209
138
  expect(cloneSchema.validate({ path: '/api/v1/users' })).toBe(false);
210
139
  expect(cloneSchema.validate({ path: '/api/v1/users/abc' })).toBe(false);
211
- // Verify the description preserves the slashes within the pattern
140
+ // Verify the description preserves the pattern (regex source keeps escaped slashes)
212
141
  const cloneDescription = cloneSchema.describe();
213
- expect(cloneDescription).toMatchObject(description);
142
+ expect(cloneDescription.properties?.path).toMatchObject({
143
+ type: 'string',
144
+ pattern: '^\\/api\\/v[0-9]+\\/users\\/[0-9]+$'
145
+ });
214
146
  });
215
147
  });
216
148
  describe('Schema Roundtrip', () => {
217
149
  it('should maintain custom validations through describe/from cycle', () => {
218
- const originalSchema = index_1.Schema.from({
219
- age: {
220
- type: 'number',
221
- validations: {
222
- min: 18,
223
- max: 100
224
- }
225
- },
226
- email: {
227
- type: 'string',
228
- validations: {
229
- email: true
230
- }
231
- },
232
- username: {
233
- type: 'string',
234
- validations: {
235
- regex: '^[a-zA-Z0-9_]+$',
236
- minLength: 3,
237
- maxLength: 20
238
- }
239
- }
150
+ const originalSchema = new index_1.Schema({
151
+ age: index_1.Schema.number().min(18).max(100),
152
+ email: index_1.Schema.email(),
153
+ username: index_1.Schema.string().regex(/^[a-zA-Z0-9_]+$/).min(3).max(20)
240
154
  });
241
155
  const description = originalSchema.describe();
242
156
  const reconstructedSchema = index_1.Schema.from(description);