easy_talk 3.0.0 → 3.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce558538c73afc10d98c0c6b5e38e68ce033a79d1c212bb91fede27f10fb3523
4
- data.tar.gz: e46a7a67b6c1a3209108a800cefca8659381236c23ce14986e971babd874908d
3
+ metadata.gz: b091eaff6c33ddcc23f0c5d0147bf28f94a7421c95a0ca47ad917af8e42fad20
4
+ data.tar.gz: 9a6c0de5afff453a566940b7b1684e2075e70b53379b2bf3b7583a46ca370c58
5
5
  SHA512:
6
- metadata.gz: b523e6cd50edc0594d98fffdb8a9de0616e5f58a2af435a23e5f4a81ac54b25692cd0bccba71c93eca812022efd298b2a9d654b34e3b8f37301ea8e7b16a4de3
7
- data.tar.gz: 7f287df819b0e09ddf5fce9506f045860dea876b0653cf7ede7dfb58e7a1d78e545933946921f0dfb4f1f02709357629f0b071c53e7c4ff945cdcf1908f5c2b6
6
+ metadata.gz: 32a49bfa38a5279340b6699ff26d8ac415c1574f83da1488d403d422d4b49cd4aea94d5424d858c5ae2326e914c4e83995a97eb67fe4b005543e6f8984d6a60c
7
+ data.tar.gz: 02ad31d7b34aac67a4777a709ffb286487675f28e6a2c18757dd87e7a006743ab6cd0f75b817fdb6abb23d3f9ef63384fcbd5f063e1eb2d0dc8a06c7107ae0e0
data/CHANGELOG.md CHANGED
@@ -1,3 +1,33 @@
1
+ ## [3.1.0] - 2025-12-18
2
+
3
+ ### Added
4
+ - **JSON Schema `$schema` Keyword Support**: Added ability to declare which JSON Schema draft version schemas conform to
5
+ - New `schema_version` configuration option supporting Draft-04, Draft-06, Draft-07, Draft 2019-09, and Draft 2020-12
6
+ - Global configuration via `EasyTalk.configure { |c| c.schema_version = :draft202012 }`
7
+ - Per-model override using `schema_version` keyword in `define_schema` block
8
+ - Support for custom schema URIs
9
+ - `$schema` only appears at root level (not in nested models)
10
+ - Default is `:none` for backward compatibility
11
+
12
+ - **JSON Schema `$id` Keyword Support**: Added ability to provide a unique identifier URI for schemas
13
+ - New `schema_id` configuration option for setting schema identifiers
14
+ - Global configuration via `EasyTalk.configure { |c| c.schema_id = 'https://example.com/schema.json' }`
15
+ - Per-model override using `schema_id` keyword in `define_schema` block
16
+ - Supports absolute URIs, relative URIs, and URN formats
17
+ - `$id` only appears at root level (not in nested models)
18
+ - Default is `nil` for backward compatibility
19
+
20
+ - **JSON Schema `$ref` and `$defs` Support**: Added ability to reference reusable schema definitions for nested models
21
+ - New `use_refs` configuration option to globally enable `$ref` for nested EasyTalk models
22
+ - Global configuration via `EasyTalk.configure { |c| c.use_refs = true }`
23
+ - Per-property override using `ref: true` or `ref: false` constraint
24
+ - Nested models are automatically added to `$defs` when `$ref` is enabled
25
+ - Supports direct model properties, `T::Array[Model]`, and `T.nilable(Model)` types
26
+ - Nilable models with `$ref` use `anyOf` with `$ref` and `null` type
27
+ - Multiple references to the same model only create one `$defs` entry
28
+ - Additional constraints (title, description) can be combined with `$ref`
29
+ - Default is `false` for backward compatibility (nested schemas are inlined)
30
+
1
31
  ## [3.0.0] - 2025-01-03
2
32
 
3
33
  ### BREAKING CHANGES
data/README.md CHANGED
@@ -16,6 +16,9 @@ EasyTalk is a Ruby library that simplifies defining and generating JSON Schema.
16
16
  * **Schema Composition**: Define EasyTalk models and reference them in other EasyTalk models to create complex schemas.
17
17
  * **Enhanced Model Integration**: Automatic instantiation of nested EasyTalk models from hash attributes.
18
18
  * **Flexible Configuration**: Global and per-model configuration options for fine-tuned control.
19
+ * **JSON Schema Version Support**: Configure the `$schema` keyword to declare which JSON Schema draft version your schemas conform to (Draft-04 through Draft 2020-12).
20
+ * **Schema Identification**: Configure the `$id` keyword to provide a unique identifier URI for your schemas.
21
+ * **Schema References**: Use `$ref` and `$defs` for reusable schema definitions, reducing duplication when models are used in multiple places.
19
22
 
20
23
  ### Use Cases
21
24
  - API request/response validation
@@ -589,6 +592,10 @@ EasyTalk.configure do |config|
589
592
  config.default_additional_properties = false # Control additional properties on all models
590
593
  config.nilable_is_optional = false # Makes T.nilable properties also optional
591
594
  config.auto_validations = true # Automatically generate ActiveModel validations
595
+ config.schema_version = :none # JSON Schema version for $schema keyword
596
+ # Options: :none, :draft202012, :draft201909, :draft7, :draft6, :draft4
597
+ config.schema_id = nil # Base URI for $id keyword (nil = no $id)
598
+ config.use_refs = false # Use $ref for nested models instead of inlining
592
599
  end
593
600
  ```
594
601
 
@@ -1168,17 +1175,657 @@ bundle exec rubocop
1168
1175
  ### Contributing Guidelines
1169
1176
  Bug reports and pull requests are welcome on GitHub at https://github.com/sergiobayona/easy_talk.
1170
1177
 
1178
+ ## JSON Schema Version (`$schema` Keyword)
1179
+
1180
+ The `$schema` keyword declares which JSON Schema dialect (draft version) a schema conforms to. EasyTalk supports configuring this at both the global and per-model level.
1181
+
1182
+ ### Why Use `$schema`?
1183
+
1184
+ The `$schema` keyword:
1185
+ - Declares the JSON Schema version your schema is written against
1186
+ - Helps validators understand which specification to use
1187
+ - Enables tooling to provide appropriate validation and autocomplete
1188
+ - Documents the schema dialect for consumers of your API
1189
+
1190
+ ### Supported Draft Versions
1191
+
1192
+ EasyTalk supports the following JSON Schema draft versions:
1193
+
1194
+ | Symbol | JSON Schema Version | URI |
1195
+ |--------|---------------------|-----|
1196
+ | `:draft202012` | Draft 2020-12 (latest) | `https://json-schema.org/draft/2020-12/schema` |
1197
+ | `:draft201909` | Draft 2019-09 | `https://json-schema.org/draft/2019-09/schema` |
1198
+ | `:draft7` | Draft-07 | `http://json-schema.org/draft-07/schema#` |
1199
+ | `:draft6` | Draft-06 | `http://json-schema.org/draft-06/schema#` |
1200
+ | `:draft4` | Draft-04 | `http://json-schema.org/draft-04/schema#` |
1201
+ | `:none` | No `$schema` output (default) | N/A |
1202
+
1203
+ ### Global Configuration
1204
+
1205
+ Configure the schema version globally to apply to all models:
1206
+
1207
+ ```ruby
1208
+ EasyTalk.configure do |config|
1209
+ config.schema_version = :draft202012 # Use JSON Schema Draft 2020-12
1210
+ end
1211
+ ```
1212
+
1213
+ With this configuration, all models will include `$schema` in their output:
1214
+
1215
+ ```ruby
1216
+ class User
1217
+ include EasyTalk::Model
1218
+
1219
+ define_schema do
1220
+ property :name, String
1221
+ end
1222
+ end
1223
+
1224
+ User.json_schema
1225
+ # => {
1226
+ # "$schema" => "https://json-schema.org/draft/2020-12/schema",
1227
+ # "type" => "object",
1228
+ # "properties" => { "name" => { "type" => "string" } },
1229
+ # "required" => ["name"],
1230
+ # "additionalProperties" => false
1231
+ # }
1232
+ ```
1233
+
1234
+ ### Per-Model Configuration
1235
+
1236
+ Override the global setting for individual models using the `schema_version` keyword in the schema definition:
1237
+
1238
+ ```ruby
1239
+ class LegacyModel
1240
+ include EasyTalk::Model
1241
+
1242
+ define_schema do
1243
+ schema_version :draft7 # Use Draft-07 for this specific model
1244
+ property :name, String
1245
+ end
1246
+ end
1247
+
1248
+ LegacyModel.json_schema
1249
+ # => {
1250
+ # "$schema" => "http://json-schema.org/draft-07/schema#",
1251
+ # "type" => "object",
1252
+ # ...
1253
+ # }
1254
+ ```
1255
+
1256
+ ### Disabling `$schema` for Specific Models
1257
+
1258
+ If you have a global schema version configured but want to exclude `$schema` from a specific model, use `:none`:
1259
+
1260
+ ```ruby
1261
+ EasyTalk.configure do |config|
1262
+ config.schema_version = :draft202012 # Global default
1263
+ end
1264
+
1265
+ class InternalModel
1266
+ include EasyTalk::Model
1267
+
1268
+ define_schema do
1269
+ schema_version :none # No $schema for this model
1270
+ property :data, String
1271
+ end
1272
+ end
1273
+
1274
+ InternalModel.json_schema
1275
+ # => {
1276
+ # "type" => "object",
1277
+ # "properties" => { "data" => { "type" => "string" } },
1278
+ # ...
1279
+ # }
1280
+ # Note: No "$schema" key present
1281
+ ```
1282
+
1283
+ ### Custom Schema URIs
1284
+
1285
+ You can also specify a custom URI if you're using a custom meta-schema or a different schema registry:
1286
+
1287
+ ```ruby
1288
+ class CustomModel
1289
+ include EasyTalk::Model
1290
+
1291
+ define_schema do
1292
+ schema_version 'https://my-company.com/schemas/v1/meta-schema.json'
1293
+ property :id, String
1294
+ end
1295
+ end
1296
+ ```
1297
+
1298
+ ### Nested Models
1299
+
1300
+ The `$schema` keyword only appears at the root level of the schema. When you have nested EasyTalk models, only the top-level model's `json_schema` output will include `$schema`:
1301
+
1302
+ ```ruby
1303
+ EasyTalk.configure do |config|
1304
+ config.schema_version = :draft202012
1305
+ end
1306
+
1307
+ class Address
1308
+ include EasyTalk::Model
1309
+ define_schema do
1310
+ property :city, String
1311
+ end
1312
+ end
1313
+
1314
+ class User
1315
+ include EasyTalk::Model
1316
+ define_schema do
1317
+ property :name, String
1318
+ property :address, Address
1319
+ end
1320
+ end
1321
+
1322
+ User.json_schema
1323
+ # => {
1324
+ # "$schema" => "https://json-schema.org/draft/2020-12/schema", # Only at root
1325
+ # "type" => "object",
1326
+ # "properties" => {
1327
+ # "name" => { "type" => "string" },
1328
+ # "address" => {
1329
+ # "type" => "object", # No $schema here
1330
+ # "properties" => { "city" => { "type" => "string" } },
1331
+ # ...
1332
+ # }
1333
+ # },
1334
+ # ...
1335
+ # }
1336
+ ```
1337
+
1338
+ ### Default Behavior
1339
+
1340
+ By default, `schema_version` is set to `:none`, meaning no `$schema` keyword is included in the generated schemas. This maintains backward compatibility with previous versions of EasyTalk.
1341
+
1342
+ ### Best Practices
1343
+
1344
+ 1. **Choose a version appropriate for your validators**: If you're using a specific JSON Schema validator, check which drafts it supports.
1345
+
1346
+ 2. **Use Draft 2020-12 for new projects**: It's the latest stable version with the most features.
1347
+
1348
+ 3. **Be consistent**: Use global configuration for consistency across your application, and only override per-model when necessary.
1349
+
1350
+ 4. **Consider your consumers**: If your schemas are consumed by external systems, ensure they support the draft version you're using.
1351
+
1352
+ ## Schema Identifier (`$id` Keyword)
1353
+
1354
+ The `$id` keyword provides a unique identifier for your JSON Schema document. EasyTalk supports configuring this at both the global and per-model level.
1355
+
1356
+ ### Why Use `$id`?
1357
+
1358
+ The `$id` keyword:
1359
+ - Establishes a unique URI identifier for the schema
1360
+ - Enables referencing schemas from other documents via `$ref`
1361
+ - Provides a base URI for resolving relative references within the schema
1362
+ - Documents the canonical location of the schema
1363
+
1364
+ ### Global Configuration
1365
+
1366
+ Configure the schema ID globally to apply to all models:
1367
+
1368
+ ```ruby
1369
+ EasyTalk.configure do |config|
1370
+ config.schema_id = 'https://example.com/schemas/base.json'
1371
+ end
1372
+ ```
1373
+
1374
+ With this configuration, all models will include `$id` in their output:
1375
+
1376
+ ```ruby
1377
+ class User
1378
+ include EasyTalk::Model
1379
+
1380
+ define_schema do
1381
+ property :name, String
1382
+ end
1383
+ end
1384
+
1385
+ User.json_schema
1386
+ # => {
1387
+ # "$id" => "https://example.com/schemas/base.json",
1388
+ # "type" => "object",
1389
+ # "properties" => { "name" => { "type" => "string" } },
1390
+ # "required" => ["name"],
1391
+ # "additionalProperties" => false
1392
+ # }
1393
+ ```
1394
+
1395
+ ### Per-Model Configuration
1396
+
1397
+ Override the global setting for individual models using the `schema_id` keyword in the schema definition:
1398
+
1399
+ ```ruby
1400
+ class User
1401
+ include EasyTalk::Model
1402
+
1403
+ define_schema do
1404
+ schema_id 'https://example.com/schemas/user.schema.json'
1405
+ property :name, String
1406
+ property :email, String
1407
+ end
1408
+ end
1409
+
1410
+ User.json_schema
1411
+ # => {
1412
+ # "$id" => "https://example.com/schemas/user.schema.json",
1413
+ # "type" => "object",
1414
+ # ...
1415
+ # }
1416
+ ```
1417
+
1418
+ ### Disabling `$id` for Specific Models
1419
+
1420
+ If you have a global schema ID configured but want to exclude `$id` from a specific model, use `:none`:
1421
+
1422
+ ```ruby
1423
+ EasyTalk.configure do |config|
1424
+ config.schema_id = 'https://example.com/schemas/default.json'
1425
+ end
1426
+
1427
+ class InternalModel
1428
+ include EasyTalk::Model
1429
+
1430
+ define_schema do
1431
+ schema_id :none # No $id for this model
1432
+ property :data, String
1433
+ end
1434
+ end
1435
+
1436
+ InternalModel.json_schema
1437
+ # => {
1438
+ # "type" => "object",
1439
+ # "properties" => { "data" => { "type" => "string" } },
1440
+ # ...
1441
+ # }
1442
+ # Note: No "$id" key present
1443
+ ```
1444
+
1445
+ ### Combining `$schema` and `$id`
1446
+
1447
+ When both `$schema` and `$id` are configured, they appear in the standard order (`$schema` first, then `$id`):
1448
+
1449
+ ```ruby
1450
+ class Product
1451
+ include EasyTalk::Model
1452
+
1453
+ define_schema do
1454
+ schema_version :draft202012
1455
+ schema_id 'https://example.com/schemas/product.schema.json'
1456
+ property :name, String
1457
+ property :price, Float
1458
+ end
1459
+ end
1460
+
1461
+ Product.json_schema
1462
+ # => {
1463
+ # "$schema" => "https://json-schema.org/draft/2020-12/schema",
1464
+ # "$id" => "https://example.com/schemas/product.schema.json",
1465
+ # "type" => "object",
1466
+ # ...
1467
+ # }
1468
+ ```
1469
+
1470
+ ### Nested Models
1471
+
1472
+ The `$id` keyword only appears at the root level of the schema. When you have nested EasyTalk models, only the top-level model's `json_schema` output will include `$id`:
1473
+
1474
+ ```ruby
1475
+ EasyTalk.configure do |config|
1476
+ config.schema_id = 'https://example.com/schemas/user.json'
1477
+ end
1478
+
1479
+ class Address
1480
+ include EasyTalk::Model
1481
+ define_schema do
1482
+ property :city, String
1483
+ end
1484
+ end
1485
+
1486
+ class User
1487
+ include EasyTalk::Model
1488
+ define_schema do
1489
+ property :name, String
1490
+ property :address, Address
1491
+ end
1492
+ end
1493
+
1494
+ User.json_schema
1495
+ # => {
1496
+ # "$id" => "https://example.com/schemas/user.json", # Only at root
1497
+ # "type" => "object",
1498
+ # "properties" => {
1499
+ # "name" => { "type" => "string" },
1500
+ # "address" => {
1501
+ # "type" => "object", # No $id here
1502
+ # "properties" => { "city" => { "type" => "string" } },
1503
+ # ...
1504
+ # }
1505
+ # },
1506
+ # ...
1507
+ # }
1508
+ ```
1509
+
1510
+ ### URI Formats
1511
+
1512
+ The `$id` accepts various URI formats:
1513
+
1514
+ ```ruby
1515
+ # Absolute URI (recommended for published schemas)
1516
+ schema_id 'https://example.com/schemas/user.schema.json'
1517
+
1518
+ # Relative URI
1519
+ schema_id 'user.schema.json'
1520
+
1521
+ # URN format
1522
+ schema_id 'urn:example:user-schema'
1523
+ ```
1524
+
1525
+ ### Default Behavior
1526
+
1527
+ By default, `schema_id` is set to `nil`, meaning no `$id` keyword is included in the generated schemas. This maintains backward compatibility with previous versions of EasyTalk.
1528
+
1529
+ ### Best Practices
1530
+
1531
+ 1. **Use absolute URIs for published schemas**: This ensures global uniqueness and enables external references.
1532
+
1533
+ 2. **Follow a consistent naming convention**: For example, `https://yourdomain.com/schemas/{model-name}.schema.json`.
1534
+
1535
+ 3. **Keep IDs stable**: Once published, avoid changing schema IDs as external systems may reference them.
1536
+
1537
+ 4. **Combine with `$schema`**: When publishing schemas, include both `$schema` (for validation) and `$id` (for identification).
1538
+
1539
+ ## Schema References (`$ref` and `$defs`)
1540
+
1541
+ The `$ref` keyword allows you to reference reusable schema definitions, reducing duplication when the same model is used in multiple places. EasyTalk supports automatic `$ref` generation for nested models.
1542
+
1543
+ ### Why Use `$ref`?
1544
+
1545
+ The `$ref` keyword:
1546
+ - Reduces schema duplication when the same model appears multiple times
1547
+ - Produces cleaner, more organized schemas
1548
+ - Improves schema readability and maintainability
1549
+ - Aligns with JSON Schema best practices for reusable definitions
1550
+
1551
+ ### Default Behavior (Inline Schemas)
1552
+
1553
+ By default, EasyTalk inlines nested model schemas directly:
1554
+
1555
+ ```ruby
1556
+ class Address
1557
+ include EasyTalk::Model
1558
+ define_schema do
1559
+ property :street, String
1560
+ property :city, String
1561
+ end
1562
+ end
1563
+
1564
+ class Person
1565
+ include EasyTalk::Model
1566
+ define_schema do
1567
+ property :name, String
1568
+ property :address, Address
1569
+ end
1570
+ end
1571
+
1572
+ Person.json_schema
1573
+ # => {
1574
+ # "type" => "object",
1575
+ # "properties" => {
1576
+ # "name" => { "type" => "string" },
1577
+ # "address" => {
1578
+ # "type" => "object",
1579
+ # "properties" => {
1580
+ # "street" => { "type" => "string" },
1581
+ # "city" => { "type" => "string" }
1582
+ # },
1583
+ # ...
1584
+ # }
1585
+ # },
1586
+ # ...
1587
+ # }
1588
+ ```
1589
+
1590
+ ### Enabling `$ref` References
1591
+
1592
+ #### Global Configuration
1593
+
1594
+ Enable `$ref` generation for all nested models:
1595
+
1596
+ ```ruby
1597
+ EasyTalk.configure do |config|
1598
+ config.use_refs = true
1599
+ end
1600
+ ```
1601
+
1602
+ With this configuration, nested models are referenced via `$ref` and their definitions are placed in `$defs`:
1603
+
1604
+ ```ruby
1605
+ Person.json_schema
1606
+ # => {
1607
+ # "type" => "object",
1608
+ # "properties" => {
1609
+ # "name" => { "type" => "string" },
1610
+ # "address" => { "$ref" => "#/$defs/Address" }
1611
+ # },
1612
+ # "$defs" => {
1613
+ # "Address" => {
1614
+ # "type" => "object",
1615
+ # "properties" => {
1616
+ # "street" => { "type" => "string" },
1617
+ # "city" => { "type" => "string" }
1618
+ # },
1619
+ # ...
1620
+ # }
1621
+ # },
1622
+ # ...
1623
+ # }
1624
+ ```
1625
+
1626
+ #### Per-Property Configuration
1627
+
1628
+ You can also enable `$ref` for specific properties using the `ref: true` constraint:
1629
+
1630
+ ```ruby
1631
+ class Person
1632
+ include EasyTalk::Model
1633
+ define_schema do
1634
+ property :name, String
1635
+ property :address, Address, ref: true # Use $ref for this property
1636
+ end
1637
+ end
1638
+ ```
1639
+
1640
+ Or disable `$ref` for specific properties when it's enabled globally:
1641
+
1642
+ ```ruby
1643
+ EasyTalk.configure do |config|
1644
+ config.use_refs = true
1645
+ end
1646
+
1647
+ class Person
1648
+ include EasyTalk::Model
1649
+ define_schema do
1650
+ property :name, String
1651
+ property :address, Address, ref: false # Inline this property despite global setting
1652
+ end
1653
+ end
1654
+ ```
1655
+
1656
+ ### Arrays of Models
1657
+
1658
+ When using `$ref` with arrays of models, the `$ref` applies to the array items:
1659
+
1660
+ ```ruby
1661
+ EasyTalk.configure do |config|
1662
+ config.use_refs = true
1663
+ end
1664
+
1665
+ class Company
1666
+ include EasyTalk::Model
1667
+ define_schema do
1668
+ property :name, String
1669
+ property :addresses, T::Array[Address]
1670
+ end
1671
+ end
1672
+
1673
+ Company.json_schema
1674
+ # => {
1675
+ # "type" => "object",
1676
+ # "properties" => {
1677
+ # "name" => { "type" => "string" },
1678
+ # "addresses" => {
1679
+ # "type" => "array",
1680
+ # "items" => { "$ref" => "#/$defs/Address" }
1681
+ # }
1682
+ # },
1683
+ # "$defs" => {
1684
+ # "Address" => { ... }
1685
+ # },
1686
+ # ...
1687
+ # }
1688
+ ```
1689
+
1690
+ You can also use the per-property `ref` constraint with arrays:
1691
+
1692
+ ```ruby
1693
+ property :addresses, T::Array[Address], ref: true
1694
+ ```
1695
+
1696
+ ### Nilable Models with `$ref`
1697
+
1698
+ When using `$ref` with nilable model types, EasyTalk uses `anyOf` to combine the reference with the null type:
1699
+
1700
+ ```ruby
1701
+ EasyTalk.configure do |config|
1702
+ config.use_refs = true
1703
+ end
1704
+
1705
+ class Person
1706
+ include EasyTalk::Model
1707
+ define_schema do
1708
+ property :name, String
1709
+ property :address, T.nilable(Address)
1710
+ end
1711
+ end
1712
+
1713
+ Person.json_schema
1714
+ # => {
1715
+ # "type" => "object",
1716
+ # "properties" => {
1717
+ # "name" => { "type" => "string" },
1718
+ # "address" => {
1719
+ # "anyOf" => [
1720
+ # { "$ref" => "#/$defs/Address" },
1721
+ # { "type" => "null" }
1722
+ # ]
1723
+ # }
1724
+ # },
1725
+ # "$defs" => {
1726
+ # "Address" => { ... }
1727
+ # },
1728
+ # ...
1729
+ # }
1730
+ ```
1731
+
1732
+ ### Multiple References to the Same Model
1733
+
1734
+ When the same model is used multiple times, it only appears once in `$defs`:
1735
+
1736
+ ```ruby
1737
+ class Person
1738
+ include EasyTalk::Model
1739
+ define_schema do
1740
+ property :name, String
1741
+ property :home_address, Address, ref: true
1742
+ property :work_address, Address, ref: true
1743
+ property :shipping_addresses, T::Array[Address], ref: true
1744
+ end
1745
+ end
1746
+
1747
+ Person.json_schema
1748
+ # => {
1749
+ # "type" => "object",
1750
+ # "properties" => {
1751
+ # "name" => { "type" => "string" },
1752
+ # "home_address" => { "$ref" => "#/$defs/Address" },
1753
+ # "work_address" => { "$ref" => "#/$defs/Address" },
1754
+ # "shipping_addresses" => {
1755
+ # "type" => "array",
1756
+ # "items" => { "$ref" => "#/$defs/Address" }
1757
+ # }
1758
+ # },
1759
+ # "$defs" => {
1760
+ # "Address" => { ... } # Only defined once
1761
+ # },
1762
+ # ...
1763
+ # }
1764
+ ```
1765
+
1766
+ ### Combining `$ref` with Other Constraints
1767
+
1768
+ You can add additional constraints alongside `$ref`:
1769
+
1770
+ ```ruby
1771
+ class Person
1772
+ include EasyTalk::Model
1773
+ define_schema do
1774
+ property :address, Address, ref: true, description: "Primary address", title: "Main Address"
1775
+ end
1776
+ end
1777
+
1778
+ Person.json_schema["properties"]["address"]
1779
+ # => {
1780
+ # "$ref" => "#/$defs/Address",
1781
+ # "description" => "Primary address",
1782
+ # "title" => "Main Address"
1783
+ # }
1784
+ ```
1785
+
1786
+ ### Interaction with `compose`
1787
+
1788
+ When using `compose` with `T::AllOf`, `T::AnyOf`, or `T::OneOf`, the composed models are also placed in `$defs`:
1789
+
1790
+ ```ruby
1791
+ class Employee
1792
+ include EasyTalk::Model
1793
+ define_schema do
1794
+ compose T::AllOf[Person, EmployeeDetails]
1795
+ property :badge_number, String
1796
+ end
1797
+ end
1798
+ ```
1799
+
1800
+ If you also have properties using `$ref`, both the composed models and property models will appear in `$defs`.
1801
+
1802
+ ### Best Practices
1803
+
1804
+ 1. **Use global configuration for consistency**: If you prefer `$ref` style, enable it globally rather than per-property.
1805
+
1806
+ 2. **Consider schema consumers**: Some JSON Schema validators and tools work better with inlined schemas, while others prefer `$ref`. Choose based on your use case.
1807
+
1808
+ 3. **Use `$ref` for frequently reused models**: If a model appears in many places, `$ref` reduces schema size and improves maintainability.
1809
+
1810
+ 4. **Keep inline for simple, single-use models**: For models used only once, inlining may be more readable.
1811
+
1812
+ ### Default Behavior
1813
+
1814
+ By default, `use_refs` is set to `false`, meaning nested models are inlined. This maintains backward compatibility with previous versions of EasyTalk.
1815
+
1171
1816
  ## JSON Schema Compatibility
1172
1817
 
1173
1818
  ### Supported Versions
1174
- EasyTalk is currently loose about JSON Schema versions. It doesn't strictly enforce or adhere to any particular version of the specification. The goal is to add more robust support for the latest JSON Schema specs in the future.
1819
+ EasyTalk supports generating schemas compatible with JSON Schema Draft-04 through Draft 2020-12. Use the `schema_version` configuration option to declare which version your schemas conform to (see [JSON Schema Version](#json-schema-version-schema-keyword) above).
1820
+
1821
+ While EasyTalk allows you to specify any draft version via the `$schema` keyword, the generated schema structure is generally compatible across versions. Some newer draft features may require manual adjustment.
1175
1822
 
1176
1823
  ### Specification Compliance
1177
1824
  To learn about current capabilities, see the [spec/easy_talk/examples](https://github.com/sergiobayona/easy_talk/tree/main/spec/easy_talk/examples) folder. The examples illustrate how EasyTalk generates JSON Schema in different scenarios.
1178
1825
 
1179
1826
  ### Known Limitations
1180
1827
  - Limited support for custom formats
1181
- - No direct support for JSON Schema draft 2020-12 features
1828
+ - Some draft-specific keywords may not be supported
1182
1829
  - Complex composition scenarios may require manual adjustment
1183
1830
 
1184
1831
  ## License
@@ -39,6 +39,9 @@ module EasyTalk
39
39
  # We'll collect required property names in this Set
40
40
  @required_properties = Set.new
41
41
 
42
+ # Collect models that are referenced via $ref for $defs generation
43
+ @ref_models = Set.new
44
+
42
45
  # Usually the name is a string (class name). Fallback to :klass if nil.
43
46
  name_for_builder = schema_definition.name ? schema_definition.name.to_sym : :klass
44
47
 
@@ -60,12 +63,20 @@ module EasyTalk
60
63
  # Start with a copy of the raw schema
61
64
  merged = @original_schema.dup
62
65
 
66
+ # Remove schema_version and schema_id as they're handled separately in json_schema output
67
+ merged.delete(:schema_version)
68
+ merged.delete(:schema_id)
69
+
63
70
  # Extract and build sub-schemas first (handles allOf/anyOf/oneOf references, etc.)
64
71
  process_subschemas(merged)
65
72
 
66
73
  # Build :properties into a final form (and find "required" props)
74
+ # This also collects models that use $ref into @ref_models
67
75
  merged[:properties] = build_properties(merged.delete(:properties))
68
76
 
77
+ # Add $defs for any models that are referenced via $ref
78
+ add_ref_model_defs(merged) if @ref_models.any?
79
+
69
80
  # Populate the final "required" array from @required_properties
70
81
  merged[:required] = @required_properties.to_a if @required_properties.any?
71
82
 
@@ -127,6 +138,7 @@ module EasyTalk
127
138
  ##
128
139
  # Builds a single property. Could be a nested schema if it has sub-properties,
129
140
  # or a standard scalar property (String, Integer, etc.).
141
+ # Also tracks EasyTalk models that should be added to $defs when using $ref.
130
142
  #
131
143
  def build_property(prop_name, prop_options)
132
144
  @property_cache ||= {}
@@ -135,9 +147,91 @@ module EasyTalk
135
147
  @property_cache[prop_name] ||= begin
136
148
  # Remove optional constraints from the property
137
149
  constraints = prop_options[:constraints].except(:optional)
150
+ prop_type = prop_options[:type]
151
+
152
+ # Track models that will use $ref for later $defs generation
153
+ collect_ref_models(prop_type, constraints)
154
+
138
155
  # Normal property: e.g. { type: String, constraints: {...} }
139
- Property.new(prop_name, prop_options[:type], constraints)
156
+ Property.new(prop_name, prop_type, constraints)
157
+ end
158
+ end
159
+
160
+ ##
161
+ # Collects EasyTalk models that will be referenced via $ref.
162
+ # These models need to be added to $defs in the final schema.
163
+ #
164
+ def collect_ref_models(prop_type, constraints)
165
+ # Check if this type should use $ref
166
+ if should_collect_ref?(prop_type, constraints)
167
+ @ref_models.add(prop_type)
168
+ # Handle typed arrays with EasyTalk model items
169
+ elsif typed_array_with_model?(prop_type)
170
+ inner_type = prop_type.type.raw_type
171
+ @ref_models.add(inner_type) if should_collect_ref?(inner_type, constraints)
172
+ # Handle nilable types
173
+ elsif nilable_with_model?(prop_type)
174
+ actual_type = T::Utils::Nilable.get_underlying_type(prop_type)
175
+ @ref_models.add(actual_type) if should_collect_ref?(actual_type, constraints)
176
+ end
177
+ end
178
+
179
+ ##
180
+ # Determines if a type should be collected for $ref based on config and constraints.
181
+ #
182
+ def should_collect_ref?(check_type, constraints)
183
+ return false unless easytalk_model?(check_type)
184
+
185
+ # Per-property constraint takes precedence
186
+ return constraints[:ref] if constraints.key?(:ref)
187
+
188
+ # Fall back to global configuration
189
+ EasyTalk.configuration.use_refs
190
+ end
191
+
192
+ ##
193
+ # Checks if a type is an EasyTalk model.
194
+ #
195
+ def easytalk_model?(check_type)
196
+ check_type.is_a?(Class) &&
197
+ check_type.respond_to?(:schema) &&
198
+ check_type.respond_to?(:ref_template) &&
199
+ defined?(EasyTalk::Model) &&
200
+ check_type.include?(EasyTalk::Model)
201
+ end
202
+
203
+ ##
204
+ # Checks if type is a typed array containing an EasyTalk model.
205
+ #
206
+ def typed_array_with_model?(prop_type)
207
+ return false unless prop_type.is_a?(T::Types::TypedArray)
208
+
209
+ inner_type = prop_type.type.raw_type
210
+ easytalk_model?(inner_type)
211
+ end
212
+
213
+ ##
214
+ # Checks if type is nilable and contains an EasyTalk model.
215
+ #
216
+ def nilable_with_model?(prop_type)
217
+ return false unless prop_type.respond_to?(:types)
218
+ return false unless prop_type.types.all? { |t| t.respond_to?(:raw_type) }
219
+ return false unless prop_type.types.any? { |t| t.raw_type == NilClass }
220
+
221
+ actual_type = T::Utils::Nilable.get_underlying_type(prop_type)
222
+ easytalk_model?(actual_type)
223
+ end
224
+
225
+ ##
226
+ # Adds $defs entries for all collected ref models.
227
+ #
228
+ def add_ref_model_defs(schema_hash)
229
+ definitions = @ref_models.each_with_object({}) do |model, acc|
230
+ acc[model.name] = model.schema
140
231
  end
232
+
233
+ existing_defs = schema_hash[:defs] || {}
234
+ schema_hash[:defs] = existing_defs.merge(definitions)
141
235
  end
142
236
 
143
237
  ##
@@ -14,7 +14,8 @@ module EasyTalk
14
14
  max_items: { type: Integer, key: :maxItems },
15
15
  unique_items: { type: T::Boolean, key: :uniqueItems },
16
16
  enum: { type: T::Array[T.untyped], key: :enum },
17
- const: { type: T::Array[T.untyped], key: :const }
17
+ const: { type: T::Array[T.untyped], key: :const },
18
+ ref: { type: T::Boolean, key: :ref }
18
19
  }.freeze
19
20
 
20
21
  attr_reader :type
@@ -35,7 +36,9 @@ module EasyTalk
35
36
  sig { returns(T::Hash[Symbol, T.untyped]) }
36
37
  def schema
37
38
  super.tap do |schema|
38
- schema[:items] = Property.new(@name, inner_type, {}).build
39
+ # Pass ref constraint to items if present (for nested model references)
40
+ item_constraints = @options&.slice(:ref) || {}
41
+ schema[:items] = Property.new(@name, inner_type, item_constraints).build
39
42
  end
40
43
  end
41
44
 
@@ -2,12 +2,32 @@
2
2
 
3
3
  module EasyTalk
4
4
  class Configuration
5
- attr_accessor :default_additional_properties, :nilable_is_optional, :auto_validations
5
+ # JSON Schema draft version URIs
6
+ SCHEMA_VERSIONS = {
7
+ draft202012: 'https://json-schema.org/draft/2020-12/schema',
8
+ draft201909: 'https://json-schema.org/draft/2019-09/schema',
9
+ draft7: 'http://json-schema.org/draft-07/schema#',
10
+ draft6: 'http://json-schema.org/draft-06/schema#',
11
+ draft4: 'http://json-schema.org/draft-04/schema#'
12
+ }.freeze
13
+
14
+ attr_accessor :default_additional_properties, :nilable_is_optional, :auto_validations, :schema_version, :schema_id,
15
+ :use_refs
6
16
 
7
17
  def initialize
8
18
  @default_additional_properties = false
9
19
  @nilable_is_optional = false
10
20
  @auto_validations = true
21
+ @schema_version = :none
22
+ @schema_id = nil
23
+ @use_refs = false
24
+ end
25
+
26
+ # Returns the URI for the configured schema version, or nil if :none
27
+ def schema_uri
28
+ return nil if @schema_version == :none
29
+
30
+ SCHEMA_VERSIONS[@schema_version] || @schema_version.to_s
11
31
  end
12
32
  end
13
33
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  module EasyTalk
4
4
  KEYWORDS = %i[
5
+ schema_id
6
+ schema_version
5
7
  description
6
8
  type
7
9
  title
@@ -155,12 +155,63 @@ module EasyTalk
155
155
  end
156
156
 
157
157
  # Returns the JSON schema for the model.
158
+ # This is the final output that includes the $schema keyword if configured.
158
159
  #
159
160
  # @return [Hash] The JSON schema for the model.
160
161
  def json_schema
161
- @json_schema ||= schema.as_json
162
+ @json_schema ||= build_json_schema
162
163
  end
163
164
 
165
+ private
166
+
167
+ # Builds the final JSON schema with optional $schema and $id keywords.
168
+ def build_json_schema
169
+ result = schema.as_json
170
+ schema_uri = resolve_schema_uri
171
+ id_uri = resolve_schema_id
172
+
173
+ # Build prefix hash with $schema and $id (in that order per JSON Schema convention)
174
+ prefix = {}
175
+ prefix['$schema'] = schema_uri if schema_uri
176
+ prefix['$id'] = id_uri if id_uri
177
+
178
+ return result if prefix.empty?
179
+
180
+ prefix.merge(result)
181
+ end
182
+
183
+ # Resolves the schema URI from per-model setting or global config.
184
+ def resolve_schema_uri
185
+ model_version = @schema_definition&.schema&.dig(:schema_version)
186
+
187
+ if model_version
188
+ # Per-model override - :none means explicitly no $schema
189
+ return nil if model_version == :none
190
+
191
+ Configuration::SCHEMA_VERSIONS[model_version] || model_version.to_s
192
+ else
193
+ # Fall back to global configuration
194
+ EasyTalk.configuration.schema_uri
195
+ end
196
+ end
197
+
198
+ # Resolves the schema ID from per-model setting or global config.
199
+ def resolve_schema_id
200
+ model_id = @schema_definition&.schema&.dig(:schema_id)
201
+
202
+ if model_id
203
+ # Per-model override - :none means explicitly no $id
204
+ return nil if model_id == :none
205
+
206
+ model_id.to_s
207
+ else
208
+ # Fall back to global configuration
209
+ EasyTalk.configuration.schema_id
210
+ end
211
+ end
212
+
213
+ public
214
+
164
215
  # Define the schema for the model using the provided block.
165
216
  #
166
217
  # @yield The block to define the schema.
@@ -101,22 +101,31 @@ module EasyTalk
101
101
  # This method handles different types of properties:
102
102
  # - Nilable types (can be null)
103
103
  # - Types with dedicated builders
104
- # - Types that implement their own schema method
104
+ # - Types that implement their own schema method (EasyTalk models)
105
105
  # - Default fallback to 'object' type
106
106
  #
107
+ # When use_refs is enabled (globally or per-property), EasyTalk models
108
+ # are referenced via $ref instead of being inlined.
109
+ #
107
110
  # @return [Hash] The complete JSON Schema property definition
108
111
  #
109
112
  # @example Simple string property
110
113
  # property = Property.new(:name, 'String')
111
114
  # property.build # => {"type"=>"string"}
112
115
  #
113
- # @example Complex nested schema
116
+ # @example Complex nested schema (inlined)
114
117
  # address = Address.new # A class with a .schema method
115
118
  # property = Property.new(:shipping_address, address, description: "Shipping address")
116
119
  # property.build # => Address schema merged with the description constraint
120
+ #
121
+ # @example Nested schema with $ref
122
+ # property = Property.new(:shipping_address, Address, ref: true)
123
+ # property.build # => {"$ref"=>"#/$defs/Address", ...constraints}
117
124
  def build
118
125
  if nilable_type?
119
126
  build_nilable_schema
127
+ elsif should_use_ref?
128
+ build_ref_schema
120
129
  elsif builder
121
130
  args = builder.collection_type? ? [name, type, constraints] : [name, constraints]
122
131
  builder.new(*args).build
@@ -193,6 +202,14 @@ module EasyTalk
193
202
 
194
203
  return { type: 'null' } unless actual_type
195
204
 
205
+ # Check if the underlying type is an EasyTalk model that should use $ref
206
+ if easytalk_model?(actual_type) && should_use_ref_for_type?(actual_type)
207
+ # Use anyOf with $ref and null type
208
+ ref_constraints = constraints.except(:ref, :optional)
209
+ schema = { anyOf: [{ '$ref': actual_type.ref_template }, { type: 'null' }] }
210
+ return ref_constraints.empty? ? schema : schema.merge(ref_constraints)
211
+ end
212
+
196
213
  # Create a property with the actual type
197
214
  non_nil_schema = Property.new(name, actual_type, constraints).build
198
215
 
@@ -201,5 +218,54 @@ module EasyTalk
201
218
  type: [non_nil_schema[:type], 'null'].compact
202
219
  )
203
220
  end
221
+
222
+ # Determines if $ref should be used for the current type.
223
+ #
224
+ # @return [Boolean] true if $ref should be used, false otherwise
225
+ # @api private
226
+ def should_use_ref?
227
+ return false unless easytalk_model?(type)
228
+
229
+ should_use_ref_for_type?(type)
230
+ end
231
+
232
+ # Determines if $ref should be used for a given type based on constraints and config.
233
+ #
234
+ # @param check_type [Class] The type to check
235
+ # @return [Boolean] true if $ref should be used, false otherwise
236
+ # @api private
237
+ def should_use_ref_for_type?(check_type)
238
+ return false unless easytalk_model?(check_type)
239
+
240
+ # Per-property constraint takes precedence
241
+ return constraints[:ref] if constraints.key?(:ref)
242
+
243
+ # Fall back to global configuration
244
+ EasyTalk.configuration.use_refs
245
+ end
246
+
247
+ # Checks if a type is an EasyTalk model.
248
+ #
249
+ # @param check_type [Object] The type to check
250
+ # @return [Boolean] true if the type is an EasyTalk model
251
+ # @api private
252
+ def easytalk_model?(check_type)
253
+ check_type.is_a?(Class) &&
254
+ check_type.respond_to?(:schema) &&
255
+ check_type.respond_to?(:ref_template) &&
256
+ defined?(EasyTalk::Model) &&
257
+ check_type.include?(EasyTalk::Model)
258
+ end
259
+
260
+ # Builds a $ref schema for an EasyTalk model.
261
+ #
262
+ # @return [Hash] A schema with $ref pointing to the model's definition
263
+ # @api private
264
+ def build_ref_schema
265
+ # Remove ref and optional from constraints as they're not JSON Schema keywords
266
+ ref_constraints = constraints.except(:ref, :optional)
267
+ schema = { '$ref': type.ref_template }
268
+ ref_constraints.empty? ? schema : schema.merge(ref_constraints)
269
+ end
204
270
  end
205
271
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EasyTalk
4
- VERSION = '3.0.0'
4
+ VERSION = '3.1.0'
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: easy_talk
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergio Bayona
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-09-03 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activemodel
@@ -147,7 +147,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
147
147
  - !ruby/object:Gem::Version
148
148
  version: '0'
149
149
  requirements: []
150
- rubygems_version: 3.6.2
150
+ rubygems_version: 3.7.2
151
151
  specification_version: 4
152
152
  summary: Generate json-schema from Ruby classes with ActiveModel integration.
153
153
  test_files: []