effect-app 4.0.0-beta.43 → 4.0.0-beta.44

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.
@@ -0,0 +1,473 @@
1
+ import { Option, Predicate, Schema, SchemaGetter } from "effect"
2
+ import * as S from "effect/Schema"
3
+ import { Class, TaggedClass } from "effect-app/Schema/Class"
4
+ import { specialJsonSchemaDocument } from "effect-app/Schema/SpecialJsonSchema"
5
+ import { deduplicateOpenApiSchemas } from "effect-app/Schema/SpecialOpenApi"
6
+ import { describe, expect, it } from "vitest"
7
+
8
+ describe("Class", () => {
9
+ it("encoding accepts plain objects matching the struct (Fields argument)", () => {
10
+ class A extends Class<A>("A")({ a: S.String }) {}
11
+
12
+ // Encoding a class instance still works
13
+ expect(S.encodeUnknownSync(A)(new A({ a: "hello" }))).toStrictEqual({ a: "hello" })
14
+
15
+ // Encoding a plain object matching the struct now succeeds
16
+ expect(S.encodeUnknownSync(A)({ a: "world" })).toStrictEqual({ a: "world" })
17
+
18
+ // Encoding null still fails
19
+ expect(() => S.encodeUnknownSync(A)(null)).toThrow()
20
+ })
21
+
22
+ it("encoding accepts plain objects matching the struct (Struct argument)", () => {
23
+ class A extends Class<A>("A")(S.Struct({ a: S.String })) {}
24
+
25
+ expect(S.encodeUnknownSync(A)(new A({ a: "hello" }))).toStrictEqual({ a: "hello" })
26
+ expect(S.encodeUnknownSync(A)({ a: "world" })).toStrictEqual({ a: "world" })
27
+ expect(() => S.encodeUnknownSync(A)(null)).toThrow()
28
+ })
29
+
30
+ it("decoding still works normally", () => {
31
+ class A extends Class<A>("A")({ a: S.String }) {}
32
+
33
+ const decoded = S.decodeUnknownSync(A)({ a: "hello" })
34
+ expect(decoded).toBeInstanceOf(A)
35
+ expect((decoded as A).a).toBe("hello")
36
+
37
+ expect(() => S.decodeUnknownSync(A)(null)).toThrow()
38
+ expect(() => S.decodeUnknownSync(A)({ a: 1 })).toThrow()
39
+ })
40
+
41
+ it("rejects values that don't match the struct", () => {
42
+ class A extends Class<A>("A")({ a: S.String }) {}
43
+
44
+ expect(() => S.encodeUnknownSync(A)({ a: 123 })).toThrow()
45
+ expect(() => S.encodeUnknownSync(A)("not an object")).toThrow()
46
+ })
47
+
48
+ it("returns a class constructor — new and instanceof work", () => {
49
+ class A extends Class<A>("A")({ a: S.String }) {}
50
+
51
+ const instance = new A({ a: "hello" })
52
+ expect(instance).toBeInstanceOf(A)
53
+ expect(instance.a).toBe("hello")
54
+ })
55
+
56
+ it("preserves fields and identifier", () => {
57
+ class A extends Class<A>("A")({ a: S.String, b: S.Number }) {}
58
+
59
+ expect(A.identifier).toBe("A")
60
+ expect(Object.keys(A.fields)).toStrictEqual(["a", "b"])
61
+ })
62
+ })
63
+
64
+ describe("Class constructor", () => {
65
+ it("works as a base class — new, instanceof, encoding plain objects", () => {
66
+ class A extends Class<A>("A")({ a: S.String }) {}
67
+
68
+ // Construction
69
+ const instance = new A({ a: "hello" })
70
+ expect(instance).toBeInstanceOf(A)
71
+ expect(instance.a).toBe("hello")
72
+
73
+ // Encoding a class instance
74
+ expect(S.encodeUnknownSync(A)(instance)).toStrictEqual({ a: "hello" })
75
+
76
+ // Encoding a plain object
77
+ expect(S.encodeUnknownSync(A)({ a: "world" })).toStrictEqual({ a: "world" })
78
+
79
+ // Encoding invalid input fails
80
+ expect(() => S.encodeUnknownSync(A)(null)).toThrow()
81
+ expect(() => S.encodeUnknownSync(A)({ a: 123 })).toThrow()
82
+ })
83
+
84
+ it("decoding works normally", () => {
85
+ class A extends Class<A>("A")({ a: S.String }) {}
86
+
87
+ const decoded = S.decodeUnknownSync(A)({ a: "hello" })
88
+ expect(decoded).toBeInstanceOf(A)
89
+ expect((decoded as A).a).toBe("hello")
90
+
91
+ expect(() => S.decodeUnknownSync(A)({ a: 1 })).toThrow()
92
+ })
93
+
94
+ it("exposes fields, identifier, pick, omit", () => {
95
+ class A extends Class<A>("A")({ a: S.String, b: S.Number }) {}
96
+
97
+ expect(A.identifier).toBe("A")
98
+ expect(Object.keys(A.fields)).toStrictEqual(["a", "b"])
99
+ expect(A.pick("a")).toStrictEqual({ a: A.fields.a })
100
+ expect(A.omit("b")).toStrictEqual({ a: A.fields.a })
101
+ })
102
+ })
103
+
104
+ describe("TaggedClass constructor", () => {
105
+ it("works as a base class with _tag — new, instanceof, encoding plain objects", () => {
106
+ class Circle extends TaggedClass<Circle>()("Circle", { radius: S.Number }) {}
107
+
108
+ // Construction
109
+ const instance = new Circle({ radius: 5 })
110
+ expect(instance).toBeInstanceOf(Circle)
111
+ expect(instance._tag).toBe("Circle")
112
+ expect(instance.radius).toBe(5)
113
+
114
+ // Encoding a class instance
115
+ expect(S.encodeUnknownSync(Circle)(instance)).toStrictEqual({ _tag: "Circle", radius: 5 })
116
+
117
+ // Encoding a plain object
118
+ expect(S.encodeUnknownSync(Circle)({ _tag: "Circle", radius: 10 })).toStrictEqual({ _tag: "Circle", radius: 10 })
119
+
120
+ // Encoding invalid input fails
121
+ expect(() => S.encodeUnknownSync(Circle)(null)).toThrow()
122
+ expect(() => S.encodeUnknownSync(Circle)({ _tag: "Circle", radius: "nope" })).toThrow()
123
+ })
124
+
125
+ it("decoding works normally", () => {
126
+ class Circle extends TaggedClass<Circle>()("Circle", { radius: S.Number }) {}
127
+
128
+ const decoded = S.decodeUnknownSync(Circle)({ _tag: "Circle", radius: 5 })
129
+ expect(decoded).toBeInstanceOf(Circle)
130
+ expect((decoded as Circle).radius).toBe(5)
131
+ expect((decoded as Circle)._tag).toBe("Circle")
132
+ })
133
+
134
+ it("exposes fields, identifier, pick, omit", () => {
135
+ class Circle extends TaggedClass<Circle>()("Circle", { radius: S.Number }) {}
136
+
137
+ expect(Circle.identifier).toBe("Circle")
138
+ expect(Object.keys(Circle.fields)).toContain("_tag")
139
+ expect(Object.keys(Circle.fields)).toContain("radius")
140
+ expect(Circle.pick("radius")).toStrictEqual({ radius: Circle.fields.radius })
141
+ })
142
+ })
143
+
144
+ describe("SpecialJsonSchema", () => {
145
+ it("nullable to optional — from NullOr", () => {
146
+ const nullableDecodedUndefinedEncoded = (schema: Schema.Top) => {
147
+ const isNullableSchema = "members" in schema
148
+ && globalThis.Array.isArray((schema as any).members)
149
+ && (schema as any).members.length === 2
150
+ && (schema as any).members.some((member: any) => member.ast._tag === "Null")
151
+
152
+ const nullableMembers = isNullableSchema ? (schema as any).members as ReadonlyArray<Schema.Top> : undefined
153
+ const innerSchema = nullableMembers
154
+ ? nullableMembers.find((member: any) => member.ast._tag !== "Null")!
155
+ : schema
156
+
157
+ const nullableSchema = isNullableSchema ? schema : Schema.NullOr(schema)
158
+
159
+ return nullableSchema.pipe(
160
+ Schema.encodeTo(Schema.optionalKey(innerSchema), {
161
+ decode: SchemaGetter.transformOptional(Option.orElseSome(() => null)),
162
+ encode: SchemaGetter.transformOptional(Option.filter(Predicate.isNotNull))
163
+ })
164
+ )
165
+ }
166
+
167
+ const fromNullOr = nullableDecodedUndefinedEncoded(Schema.NullOr(Schema.String))
168
+ const structFromNullOr = Schema.Struct({ status: fromNullOr })
169
+
170
+ const encode = Schema.encodeUnknownSync(structFromNullOr as any)
171
+ const encodedNull = encode({ status: null }) as any
172
+ expect("status" in encodedNull).toBe(false)
173
+ expect(encode({ status: "test" })).toStrictEqual({ status: "test" })
174
+
175
+ const decode = Schema.decodeUnknownSync(structFromNullOr as any)
176
+ expect(decode({})).toStrictEqual({ status: null })
177
+ expect(decode({ status: "test" })).toStrictEqual({ status: "test" })
178
+
179
+ const doc = specialJsonSchemaDocument(structFromNullOr)
180
+ expect(doc).toStrictEqual({
181
+ dialect: "draft-2020-12",
182
+ schema: {
183
+ "type": "object",
184
+ "properties": {
185
+ "status": { "type": "string" }
186
+ },
187
+ "additionalProperties": false
188
+ },
189
+ definitions: {}
190
+ })
191
+ })
192
+
193
+ it("identifies X universally — deduplicates same-fingerprint references", () => {
194
+ const X = Schema.String.annotate({ title: "X", identifier: "X" })
195
+
196
+ const s = Schema.Struct({
197
+ a: Schema.NullOr(X).pipe(
198
+ Schema.encodeTo(Schema.optionalKey(X), {
199
+ decode: SchemaGetter.transformOptional(Option.orElseSome(() => null)),
200
+ encode: SchemaGetter.transformOptional(Option.filter(Predicate.isNotNull))
201
+ })
202
+ ),
203
+ b: Schema.NullOr(X).pipe(
204
+ Schema.encodeTo(Schema.optionalKey(X), {
205
+ decode: SchemaGetter.transformOptional(Option.orElseSome(() => null)),
206
+ encode: SchemaGetter.transformOptional(Option.filter(Predicate.isNotNull))
207
+ })
208
+ ),
209
+ c: Schema.NullOr(X),
210
+ d: X,
211
+ e: X.pipe(Schema.optionalKey)
212
+ })
213
+
214
+ const doc = specialJsonSchemaDocument(s)
215
+ expect(doc).toStrictEqual({
216
+ dialect: "draft-2020-12",
217
+ schema: {
218
+ "type": "object",
219
+ "properties": {
220
+ "a": { "$ref": "#/$defs/X" },
221
+ "b": { "$ref": "#/$defs/X" },
222
+ "c": {
223
+ "anyOf": [
224
+ { "$ref": "#/$defs/X" },
225
+ { "type": "null" }
226
+ ]
227
+ },
228
+ "d": { "$ref": "#/$defs/X" },
229
+ "e": { "$ref": "#/$defs/X" }
230
+ },
231
+ "required": ["c", "d"],
232
+ "additionalProperties": false
233
+ },
234
+ definitions: {
235
+ X: {
236
+ "type": "string",
237
+ "title": "X"
238
+ }
239
+ }
240
+ })
241
+ })
242
+
243
+ it("shared annotated schema via helper — deduplicates", () => {
244
+ const X = Schema.String.annotate({ title: "X", identifier: "X" })
245
+
246
+ const cache = new WeakMap()
247
+ const nullableDecodedUndefinedEncoded = (schema: Schema.Top) => {
248
+ const isNullableSchema = "members" in schema
249
+ && globalThis.Array.isArray((schema as any).members)
250
+ && (schema as any).members.length === 2
251
+ && (schema as any).members.some((member: any) => member.ast._tag === "Null")
252
+
253
+ const nullableMembers = isNullableSchema ? (schema as any).members as ReadonlyArray<Schema.Top> : undefined
254
+ const innerSchema = nullableMembers
255
+ ? nullableMembers.find((member: any) => member.ast._tag !== "Null")!
256
+ : schema
257
+
258
+ const cached = cache.get(innerSchema.ast)
259
+ if (cached !== undefined) return cached
260
+
261
+ const nullableSchema = isNullableSchema ? schema : Schema.NullOr(schema)
262
+ const out = nullableSchema.pipe(
263
+ Schema.encodeTo(Schema.optionalKey(innerSchema), {
264
+ decode: SchemaGetter.transformOptional(Option.orElseSome(() => null)),
265
+ encode: SchemaGetter.transformOptional(Option.filter(Predicate.isNotNull))
266
+ })
267
+ )
268
+
269
+ cache.set(innerSchema.ast, out)
270
+ return out
271
+ }
272
+
273
+ const structWithShared = Schema.Struct({
274
+ a: nullableDecodedUndefinedEncoded(X),
275
+ b: nullableDecodedUndefinedEncoded(Schema.NullOr(X)),
276
+ c: Schema.NullOr(X),
277
+ d: X,
278
+ e: X.pipe(Schema.optionalKey)
279
+ })
280
+
281
+ const doc = specialJsonSchemaDocument(structWithShared)
282
+ expect(doc).toStrictEqual({
283
+ dialect: "draft-2020-12",
284
+ schema: {
285
+ "type": "object",
286
+ "properties": {
287
+ "a": { "$ref": "#/$defs/X" },
288
+ "b": { "$ref": "#/$defs/X" },
289
+ "c": {
290
+ "anyOf": [
291
+ { "$ref": "#/$defs/X" },
292
+ { "type": "null" }
293
+ ]
294
+ },
295
+ "d": { "$ref": "#/$defs/X" },
296
+ "e": { "$ref": "#/$defs/X" }
297
+ },
298
+ "required": ["c", "d"],
299
+ "additionalProperties": false
300
+ },
301
+ definitions: {
302
+ X: {
303
+ "type": "string",
304
+ "title": "X"
305
+ }
306
+ }
307
+ })
308
+ })
309
+ })
310
+
311
+ describe("SpecialOpenApi", () => {
312
+ it("deduplicates identical components.schemas entries with same base identifier", () => {
313
+ const spec = {
314
+ openapi: "3.1.0",
315
+ info: { title: "Test", version: "1.0" },
316
+ paths: {
317
+ "/foo": {
318
+ get: {
319
+ responses: {
320
+ 200: {
321
+ content: {
322
+ "application/json": {
323
+ schema: { $ref: "#/components/schemas/X" }
324
+ }
325
+ }
326
+ }
327
+ }
328
+ }
329
+ },
330
+ "/bar": {
331
+ get: {
332
+ responses: {
333
+ 200: {
334
+ content: {
335
+ "application/json": {
336
+ schema: { $ref: "#/components/schemas/X1" }
337
+ }
338
+ }
339
+ }
340
+ }
341
+ }
342
+ }
343
+ },
344
+ components: {
345
+ schemas: {
346
+ X: { type: "string", title: "X" },
347
+ X1: { type: "string", title: "X" }
348
+ }
349
+ }
350
+ }
351
+
352
+ const result = deduplicateOpenApiSchemas(spec) as any
353
+
354
+ // X1 should be removed, and $ref to X1 rewritten to X
355
+ expect(result.components.schemas).toStrictEqual({
356
+ X: { type: "string", title: "X" }
357
+ })
358
+ expect(
359
+ result.paths["/bar"].get.responses[200].content["application/json"].schema
360
+ ).toStrictEqual({ $ref: "#/components/schemas/X" })
361
+ })
362
+
363
+ it("does not deduplicate entries with different representations", () => {
364
+ const spec = {
365
+ openapi: "3.1.0",
366
+ info: { title: "Test", version: "1.0" },
367
+ paths: {},
368
+ components: {
369
+ schemas: {
370
+ X: { type: "string", title: "X" },
371
+ X1: { type: "number", title: "X" }
372
+ }
373
+ }
374
+ }
375
+
376
+ const result = deduplicateOpenApiSchemas(spec) as any
377
+
378
+ // Both should remain since they have different representations
379
+ expect(result.components.schemas).toStrictEqual({
380
+ X: { type: "string", title: "X" },
381
+ X1: { type: "number", title: "X" }
382
+ })
383
+ })
384
+
385
+ it("returns spec unchanged when no duplicates exist", () => {
386
+ const spec = {
387
+ openapi: "3.1.0",
388
+ info: { title: "Test", version: "1.0" },
389
+ paths: {},
390
+ components: {
391
+ schemas: {
392
+ Foo: { type: "string" },
393
+ Bar: { type: "number" }
394
+ }
395
+ }
396
+ }
397
+
398
+ const result = deduplicateOpenApiSchemas(spec)
399
+ expect(result).toBe(spec) // same reference, no cloning needed
400
+ })
401
+
402
+ it("rewrites nested $ref pointers in allOf/anyOf/oneOf", () => {
403
+ const spec = {
404
+ openapi: "3.1.0",
405
+ info: { title: "Test", version: "1.0" },
406
+ paths: {
407
+ "/baz": {
408
+ post: {
409
+ requestBody: {
410
+ content: {
411
+ "application/json": {
412
+ schema: {
413
+ anyOf: [
414
+ { $ref: "#/components/schemas/Y1" },
415
+ { type: "null" }
416
+ ]
417
+ }
418
+ }
419
+ }
420
+ }
421
+ }
422
+ }
423
+ },
424
+ components: {
425
+ schemas: {
426
+ Y: { type: "object", properties: { name: { type: "string" } } },
427
+ Y1: { type: "object", properties: { name: { type: "string" } } }
428
+ }
429
+ }
430
+ }
431
+
432
+ const result = deduplicateOpenApiSchemas(spec) as any
433
+
434
+ expect(result.components.schemas).toStrictEqual({
435
+ Y: { type: "object", properties: { name: { type: "string" } } }
436
+ })
437
+ expect(
438
+ result.paths["/baz"].post.requestBody.content["application/json"].schema.anyOf[0]
439
+ ).toStrictEqual({ $ref: "#/components/schemas/Y" })
440
+ })
441
+
442
+ it("rewrites $ref pointers inside definitions themselves", () => {
443
+ const spec = {
444
+ openapi: "3.1.0",
445
+ info: { title: "Test", version: "1.0" },
446
+ paths: {},
447
+ components: {
448
+ schemas: {
449
+ Inner: { type: "string" },
450
+ Inner1: { type: "string" },
451
+ Outer: {
452
+ type: "object",
453
+ properties: {
454
+ field: { $ref: "#/components/schemas/Inner1" }
455
+ }
456
+ }
457
+ }
458
+ }
459
+ }
460
+
461
+ const result = deduplicateOpenApiSchemas(spec) as any
462
+
463
+ expect(Object.keys(result.components.schemas)).toStrictEqual(["Inner", "Outer"])
464
+ expect(result.components.schemas.Outer.properties.field).toStrictEqual({
465
+ $ref: "#/components/schemas/Inner"
466
+ })
467
+ })
468
+
469
+ it("handles spec without components gracefully", () => {
470
+ const spec = { openapi: "3.1.0", info: { title: "Test", version: "1.0" }, paths: {} }
471
+ expect(deduplicateOpenApiSchemas(spec)).toBe(spec)
472
+ })
473
+ })