├── .editorconfig ├── .github └── workflows │ ├── ci.yml │ └── deployment.yml ├── .gitignore ├── LICENSE ├── README.md ├── shard.yml ├── spec ├── cr_serializer_spec.cr ├── exclusion_strategies │ ├── groups_spec.cr │ └── version_spec.cr ├── formats │ └── json_spec.cr ├── models │ ├── accessor.cr │ ├── accessor_order.cr │ ├── emit_null.cr │ ├── exclude.cr │ ├── expose.cr │ ├── groups.cr │ ├── ignore_on_deserialize.cr │ ├── ignore_on_serialize.cr │ ├── name.cr │ ├── post_deserialize.cr │ ├── post_serialize.cr │ ├── pre_serialize.cr │ ├── read_only.cr │ ├── skip.cr │ ├── skip_when_empty.cr │ └── virtual_property.cr ├── serialization_context_spec.cr └── spec_helper.cr └── src ├── CrSerializer.cr ├── annotations.cr ├── context.cr ├── deserialization_context.cr ├── exceptions ├── logic_exception.cr └── parse_exception.cr ├── exclusion_policy.cr ├── exclusion_strategies ├── disjunct.cr ├── exclusion_strategy.cr ├── groups.cr └── version.cr ├── formats ├── common.cr └── json.cr ├── property_metadata.cr └── serialization_context.cr /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.cr] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*/*' 7 | - '*' 8 | - '!master' 9 | 10 | jobs: 11 | check_format: 12 | runs-on: ubuntu-latest 13 | container: 14 | image: crystallang/crystal 15 | steps: 16 | - uses: actions/checkout@v1 17 | - name: Format 18 | run: crystal tool format --check 19 | coding_standards: 20 | runs-on: ubuntu-latest 21 | container: 22 | image: crystallang/crystal 23 | steps: 24 | - uses: actions/checkout@v1 25 | - name: Install Dependencies 26 | run: shards install 27 | - name: Ameba 28 | run: ./bin/ameba 29 | test_latest: 30 | runs-on: ubuntu-latest 31 | container: 32 | image: crystallang/crystal 33 | steps: 34 | - uses: actions/checkout@v1 35 | - name: Specs 36 | run: crystal spec --warnings all --error-on-warnings 37 | test_nightly: 38 | runs-on: ubuntu-latest 39 | container: 40 | image: crystallang/crystal:nightly 41 | steps: 42 | - uses: actions/checkout@v1 43 | - name: Specs 44 | run: crystal spec --warnings all --error-on-warnings 45 | -------------------------------------------------------------------------------- /.github/workflows/deployment.yml: -------------------------------------------------------------------------------- 1 | name: Deployment 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | deploy_docs: 10 | runs-on: ubuntu-latest 11 | container: 12 | image: crystallang/crystal 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Build 16 | run: crystal docs 17 | - name: Deploy 18 | uses: JamesIves/github-pages-deploy-action@2.0.1 19 | env: 20 | ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} 21 | BRANCH: gh-pages 22 | FOLDER: docs 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /lib/ 2 | /bin/ 3 | /.shards/ 4 | *.dwarf 5 | /.idea/ 6 | /docs/ 7 | 8 | # Libraries don't need dependency lock 9 | # Dependencies will be locked in application that uses them 10 | /shard.lock 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Blacksmoke16 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deprecated in favor of Athena's [Serializer](https://github.com/athena-framework/serializer) component. 2 | 3 | # CrSerializer 4 | [![Latest release](https://img.shields.io/github/release/Blacksmoke16/CrSerializer.svg?style=flat-square)](https://github.com/Blacksmoke16/CrSerializer/releases) 5 | 6 | Extensible annotation based serialization/deserialization library inspired by [JMS Serializer Annotations](https://jmsyst.com/libs/serializer/master/reference/annotations). 7 | 8 | ## Documentation 9 | 10 | Everything is documented in the [API Docs](https://blacksmoke16.github.io/CrSerializer/CrSerializer.html). 11 | 12 | ## Installation 13 | 14 | Add this to your application's `shard.yml`: 15 | 16 | ```yaml 17 | dependencies: 18 | CrSerializer: 19 | github: Blacksmoke16/CrSerializer 20 | ``` 21 | 22 | ## Contributing 23 | 24 | 1. Fork it (https://github.com/Blacksmoke16/CrSerializer/fork) 25 | 2. Create your feature branch (`git checkout -b my-new-feature`) 26 | 3. Commit your changes (`git commit -am 'Add some feature'`) 27 | 4. Push to the branch (`git push origin my-new-feature`) 28 | 5. Create a new Pull Request 29 | 30 | ## Contributors 31 | 32 | - [Blacksmoke16](https://github.com/Blacksmoke16) Blacksmoke16 - creator, maintainer 33 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: CrSerializer 2 | 3 | description: | 4 | Extensible annotation based serialization/deserialization library. 5 | 6 | version: 0.9.1 7 | 8 | authors: 9 | - Blacksmoke16 10 | 11 | crystal: 0.31.0 12 | 13 | license: MIT 14 | 15 | development_dependencies: 16 | ameba: 17 | github: crystal-ameba/ameba 18 | version: ~> 0.11.0 19 | -------------------------------------------------------------------------------- /spec/cr_serializer_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe CRS do 4 | describe ".deserialize" do 5 | describe CRS::Groups do 6 | describe "without any groups in the context" do 7 | it "should include all properties" do 8 | TEST.assert_properties = ->(properties : Array(CrSerializer::Metadata), _context : CrSerializer::Context) do 9 | properties.size.should eq 4 10 | 11 | p = properties[0] 12 | 13 | p.name.should eq "id" 14 | p.external_name.should eq "id" 15 | p.groups.should eq ["list", "details"] 16 | 17 | p = properties[1] 18 | 19 | p.name.should eq "comment_summaries" 20 | p.external_name.should eq "comment_summaries" 21 | p.groups.should eq ["list"] 22 | 23 | p = properties[2] 24 | 25 | p.name.should eq "comments" 26 | p.external_name.should eq "comments" 27 | p.groups.should eq ["details"] 28 | 29 | p = properties[3] 30 | 31 | p.name.should eq "created_at" 32 | p.external_name.should eq "created_at" 33 | p.groups.should eq ["default"] 34 | end 35 | 36 | Group.deserialize TEST, "" 37 | end 38 | end 39 | 40 | describe "with a group specified" do 41 | it "should exclude properties not in the given groups" do 42 | TEST.assert_properties = ->(properties : Array(CrSerializer::Metadata), _context : CrSerializer::Context) do 43 | properties.size.should eq 2 44 | 45 | p = properties[0] 46 | 47 | p.name.should eq "id" 48 | p.external_name.should eq "id" 49 | p.groups.should eq ["list", "details"] 50 | 51 | p = properties[1] 52 | 53 | p.name.should eq "comment_summaries" 54 | p.external_name.should eq "comment_summaries" 55 | p.groups.should eq ["list"] 56 | end 57 | 58 | Group.deserialize TEST, "", CrSerializer::DeserializationContext.new.groups = ["list"] 59 | end 60 | end 61 | 62 | describe "that is in the default group" do 63 | it "should include properties without groups explicitally defined" do 64 | TEST.assert_properties = ->(properties : Array(CrSerializer::Metadata), _context : CrSerializer::Context) do 65 | properties.size.should eq 3 66 | 67 | p = properties[0] 68 | 69 | p.name.should eq "id" 70 | p.external_name.should eq "id" 71 | p.groups.should eq ["list", "details"] 72 | 73 | p = properties[1] 74 | 75 | p.name.should eq "comment_summaries" 76 | p.external_name.should eq "comment_summaries" 77 | p.groups.should eq ["list"] 78 | 79 | p = properties[2] 80 | 81 | p.name.should eq "created_at" 82 | p.external_name.should eq "created_at" 83 | p.groups.should eq ["default"] 84 | end 85 | 86 | Group.deserialize TEST, "", CrSerializer::DeserializationContext.new.groups = ["list", "default"] 87 | end 88 | end 89 | end 90 | 91 | describe CRS::PostDeserialize do 92 | it "should run pre serialize methods" do 93 | TEST.assert_properties = ->(properties : Array(CrSerializer::Metadata), _context : CrSerializer::Context) do 94 | properties.size.should eq 1 95 | 96 | p = properties[0] 97 | 98 | p.name.should eq "name" 99 | p.external_name.should eq "name" 100 | 101 | nil 102 | end 103 | 104 | obj = PostDeserialize.deserialize TEST, "" 105 | obj.name.should eq "First Last" 106 | obj.first_name.should eq "First" 107 | obj.last_name.should eq "Last" 108 | end 109 | end 110 | end 111 | 112 | describe ".deserialization_properties" do 113 | pending CRS::Accessor do 114 | it "should set the value with the method" do 115 | end 116 | end 117 | 118 | describe CRS::Discriminator do 119 | end 120 | 121 | describe CRS::ExclusionPolicy do 122 | describe :all do 123 | describe CRS::Expose do 124 | it "should only return properties that are exposed or are IgnoreOnSerialize" do 125 | properties = Expose.deserialization_properties 126 | properties.size.should eq 2 127 | 128 | p = properties[0] 129 | 130 | p.name.should eq "name" 131 | p.external_name.should eq "name" 132 | 133 | p = properties[1] 134 | 135 | p.name.should eq "ignored_serialize" 136 | p.external_name.should eq "ignored_serialize" 137 | end 138 | end 139 | end 140 | 141 | describe :none do 142 | describe CRS::Exclude do 143 | it "should only return properties that are not excluded or are IgnoreOnSerialize" do 144 | properties = Exclude.deserialization_properties 145 | properties.size.should eq 2 146 | 147 | p = properties[0] 148 | 149 | p.name.should eq "name" 150 | p.external_name.should eq "name" 151 | 152 | p = properties[1] 153 | 154 | p.name.should eq "ignored_serialize" 155 | p.external_name.should eq "ignored_serialize" 156 | end 157 | end 158 | end 159 | end 160 | 161 | describe CRS::Name do 162 | describe :deserialize do 163 | it "should use the value in the annotation or property name if it wasnt defined" do 164 | properties = DeserializedName.deserialization_properties 165 | properties.size.should eq 2 166 | 167 | p = properties[0] 168 | 169 | p.name.should eq "custom_name" 170 | p.external_name.should eq "des" 171 | p.aliases.should eq [] of String 172 | 173 | p = properties[1] 174 | 175 | p.name.should eq "default_name" 176 | p.external_name.should eq "default_name" 177 | p.aliases.should eq [] of String 178 | end 179 | end 180 | 181 | describe :aliases do 182 | it "should set the aliases" do 183 | properties = AliasName.deserialization_properties 184 | properties.size.should eq 1 185 | 186 | p = properties[0] 187 | 188 | p.name.should eq "some_value" 189 | p.external_name.should eq "some_value" 190 | p.aliases.should eq ["val", "value", "some_value"] 191 | end 192 | end 193 | end 194 | 195 | describe CRS::ReadOnly do 196 | it "should not include read-only properties" do 197 | properties = ReadOnly.deserialization_properties 198 | properties.size.should eq 1 199 | 200 | p = properties[0] 201 | 202 | p.name.should eq "name" 203 | p.external_name.should eq "name" 204 | end 205 | end 206 | 207 | describe CRS::Skip do 208 | it "should not include skipped properties" do 209 | properties = Skip.deserialization_properties 210 | properties.size.should eq 1 211 | 212 | p = properties[0] 213 | 214 | p.name.should eq "one" 215 | p.external_name.should eq "one" 216 | end 217 | end 218 | 219 | describe CRS::IgnoreOnDeserialize do 220 | it "should not include ignored properties" do 221 | properties = IgnoreOnDeserialize.deserialization_properties 222 | properties.size.should eq 1 223 | 224 | p = properties[0] 225 | 226 | p.name.should eq "name" 227 | p.external_name.should eq "name" 228 | end 229 | end 230 | end 231 | 232 | describe "#serialize" do 233 | describe CRS::PreSerialize do 234 | it "should run pre serialize methods" do 235 | obj = PreSerialize.new 236 | obj.name.should be_nil 237 | obj.age.should be_nil 238 | 239 | TEST.assert_properties = ->(properties : Array(CrSerializer::Metadata), _context : CrSerializer::Context) do 240 | properties.size.should eq 2 241 | p = properties[0] 242 | 243 | p.name.should eq "name" 244 | p.external_name.should eq "name" 245 | p.value.should eq "NAME" 246 | p.skip_when_empty?.should be_false 247 | p.groups.should eq ["default"] of String 248 | p.type.should eq String? 249 | p.class.should eq PreSerialize 250 | 251 | p = properties[1] 252 | 253 | p.name.should eq "age" 254 | p.external_name.should eq "age" 255 | p.value.should eq 123 256 | p.skip_when_empty?.should be_false 257 | p.groups.should eq ["default"] of String 258 | p.type.should eq Int32? 259 | p.class.should eq PreSerialize 260 | end 261 | 262 | obj.serialize TEST 263 | 264 | obj.name.should eq "NAME" 265 | obj.age.should eq 123 266 | end 267 | end 268 | 269 | describe CRS::PostSerialize do 270 | it "should run pre serialize methods" do 271 | obj = PostSerialize.new 272 | obj.name.should be_nil 273 | obj.age.should be_nil 274 | 275 | TEST.assert_properties = ->(properties : Array(CrSerializer::Metadata), _context : CrSerializer::Context) do 276 | properties.size.should eq 2 277 | p = properties[0] 278 | 279 | p.name.should eq "name" 280 | p.external_name.should eq "name" 281 | p.value.should eq "NAME" 282 | p.skip_when_empty?.should be_false 283 | p.groups.should eq ["default"] of String 284 | p.type.should eq String? 285 | p.class.should eq PostSerialize 286 | 287 | p = properties[1] 288 | 289 | p.name.should eq "age" 290 | p.external_name.should eq "age" 291 | p.value.should eq 123 292 | p.skip_when_empty?.should be_false 293 | p.groups.should eq ["default"] of String 294 | p.type.should eq Int32? 295 | p.class.should eq PostSerialize 296 | end 297 | 298 | obj.serialize TEST 299 | 300 | obj.name.should be_nil 301 | obj.age.should be_nil 302 | end 303 | end 304 | 305 | describe CRS::SkipWhenEmpty do 306 | it "should not serialize empty properties" do 307 | obj = SkipWhenEmpty.new 308 | obj.value = "" 309 | 310 | TEST.assert_properties = ->(properties : Array(CrSerializer::Metadata), _context : CrSerializer::Context) do 311 | properties.should be_empty 312 | end 313 | 314 | obj.serialize TEST 315 | end 316 | 317 | it "should serialize non-empty properties" do 318 | obj = SkipWhenEmpty.new 319 | 320 | TEST.assert_properties = ->(properties : Array(CrSerializer::Metadata), _context : CrSerializer::Context) do 321 | properties.size.should eq 1 322 | p = properties[0] 323 | 324 | p.name.should eq "value" 325 | p.external_name.should eq "value" 326 | p.value.should eq "value" 327 | p.skip_when_empty?.should be_true 328 | p.groups.should eq ["default"] of String 329 | p.type.should eq String 330 | p.class.should eq SkipWhenEmpty 331 | end 332 | 333 | obj.serialize TEST 334 | end 335 | end 336 | 337 | describe :emit_nil do 338 | describe "with the default value" do 339 | it "should not include nil values" do 340 | obj = EmitNil.new 341 | 342 | TEST.assert_properties = ->(properties : Array(CrSerializer::Metadata), context : CrSerializer::Context) do 343 | context.as(CrSerializer::SerializationContext).emit_nil?.should be_false 344 | 345 | properties.size.should eq 1 346 | p = properties[0] 347 | 348 | p.name.should eq "age" 349 | p.external_name.should eq "age" 350 | p.value.should eq 1 351 | p.skip_when_empty?.should be_false 352 | p.groups.should eq ["default"] of String 353 | p.type.should eq Int32 354 | p.class.should eq EmitNil 355 | end 356 | 357 | obj.serialize TEST 358 | end 359 | end 360 | 361 | describe "when enabled" do 362 | it "should include nil values" do 363 | obj = EmitNil.new 364 | ctx = CrSerializer::SerializationContext.new 365 | ctx.emit_nil = true 366 | 367 | TEST.assert_properties = ->(properties : Array(CrSerializer::Metadata), context : CrSerializer::Context) do 368 | context.as(CrSerializer::SerializationContext).emit_nil?.should be_true 369 | 370 | properties.size.should eq 2 371 | p = properties[0] 372 | 373 | p.name.should eq "name" 374 | p.external_name.should eq "name" 375 | p.value.should eq nil 376 | p.skip_when_empty?.should be_false 377 | p.groups.should eq ["default"] of String 378 | p.type.should eq String? 379 | p.class.should eq EmitNil 380 | 381 | p = properties[1] 382 | 383 | p.name.should eq "age" 384 | p.external_name.should eq "age" 385 | p.value.should eq 1 386 | p.skip_when_empty?.should be_false 387 | p.groups.should eq ["default"] of String 388 | p.type.should eq Int32 389 | p.class.should eq EmitNil 390 | end 391 | 392 | obj.serialize TEST, ctx 393 | end 394 | end 395 | end 396 | 397 | describe CRS::Groups do 398 | describe "without any groups in the context" do 399 | it "should include all properties" do 400 | obj = Group.new 401 | 402 | TEST.assert_properties = ->(properties : Array(CrSerializer::Metadata), _context : CrSerializer::Context) do 403 | properties.size.should eq 4 404 | 405 | p = properties[0] 406 | 407 | p.name.should eq "id" 408 | p.external_name.should eq "id" 409 | p.value.should eq 1 410 | p.skip_when_empty?.should be_false 411 | p.groups.should eq ["list", "details"] 412 | p.type.should eq Int64 413 | p.class.should eq Group 414 | 415 | p = properties[1] 416 | 417 | p.name.should eq "comment_summaries" 418 | p.external_name.should eq "comment_summaries" 419 | p.value.should eq ["Sentence 1.", "Sentence 2."] 420 | p.skip_when_empty?.should be_false 421 | p.groups.should eq ["list"] 422 | p.type.should eq Array(String) 423 | p.class.should eq Group 424 | 425 | p = properties[2] 426 | 427 | p.name.should eq "comments" 428 | p.external_name.should eq "comments" 429 | p.value.should eq ["Sentence 1. Another sentence.", "Sentence 2. Some other stuff."] 430 | p.skip_when_empty?.should be_false 431 | p.groups.should eq ["details"] 432 | p.type.should eq Array(String) 433 | p.class.should eq Group 434 | 435 | p = properties[3] 436 | 437 | p.name.should eq "created_at" 438 | p.external_name.should eq "created_at" 439 | p.value.should eq Time.utc(2019, 1, 1) 440 | p.skip_when_empty?.should be_false 441 | p.groups.should eq ["default"] 442 | p.type.should eq Time 443 | p.class.should eq Group 444 | end 445 | 446 | obj.serialize TEST 447 | end 448 | end 449 | 450 | describe "with a group specified" do 451 | it "should exclude properties not in the given groups" do 452 | obj = Group.new 453 | 454 | TEST.assert_properties = ->(properties : Array(CrSerializer::Metadata), _context : CrSerializer::Context) do 455 | properties.size.should eq 2 456 | 457 | p = properties[0] 458 | 459 | p.name.should eq "id" 460 | p.external_name.should eq "id" 461 | p.value.should eq 1 462 | p.skip_when_empty?.should be_false 463 | p.groups.should eq ["list", "details"] 464 | p.type.should eq Int64 465 | p.class.should eq Group 466 | 467 | p = properties[1] 468 | 469 | p.name.should eq "comment_summaries" 470 | p.external_name.should eq "comment_summaries" 471 | p.value.should eq ["Sentence 1.", "Sentence 2."] 472 | p.skip_when_empty?.should be_false 473 | p.groups.should eq ["list"] 474 | p.type.should eq Array(String) 475 | p.class.should eq Group 476 | end 477 | 478 | obj.serialize TEST, CrSerializer::SerializationContext.new.groups = ["list"] 479 | end 480 | end 481 | 482 | describe "that is in the default group" do 483 | it "should include properties without groups explicitally defined" do 484 | obj = Group.new 485 | 486 | TEST.assert_properties = ->(properties : Array(CrSerializer::Metadata), _context : CrSerializer::Context) do 487 | properties.size.should eq 3 488 | 489 | p = properties[0] 490 | 491 | p.name.should eq "id" 492 | p.external_name.should eq "id" 493 | p.value.should eq 1 494 | p.skip_when_empty?.should be_false 495 | p.groups.should eq ["list", "details"] 496 | p.type.should eq Int64 497 | p.class.should eq Group 498 | 499 | p = properties[1] 500 | 501 | p.name.should eq "comment_summaries" 502 | p.external_name.should eq "comment_summaries" 503 | p.value.should eq ["Sentence 1.", "Sentence 2."] 504 | p.skip_when_empty?.should be_false 505 | p.groups.should eq ["list"] 506 | p.type.should eq Array(String) 507 | p.class.should eq Group 508 | 509 | p = properties[2] 510 | 511 | p.name.should eq "created_at" 512 | p.external_name.should eq "created_at" 513 | p.value.should eq Time.utc(2019, 1, 1) 514 | p.skip_when_empty?.should be_false 515 | p.groups.should eq ["default"] 516 | p.type.should eq Time 517 | p.class.should eq Group 518 | end 519 | 520 | obj.serialize TEST, CrSerializer::SerializationContext.new.groups = ["list", "default"] 521 | end 522 | end 523 | end 524 | end 525 | 526 | describe "#serialization_properties" do 527 | describe CRS::Accessor do 528 | it "should use the value of the method" do 529 | properties = Accessor.new.serialization_properties 530 | properties.size.should eq 1 531 | 532 | p = properties[0] 533 | 534 | p.name.should eq "foo" 535 | p.external_name.should eq "foo" 536 | p.value.should eq "FOO" 537 | p.skip_when_empty?.should be_false 538 | p.type.should eq String 539 | p.class.should eq Accessor 540 | end 541 | end 542 | 543 | describe CRS::AccessorOrder do 544 | describe :default do 545 | it "should used the order in which the properties were defined" do 546 | properties = Default.new.serialization_properties 547 | properties.size.should eq 6 548 | 549 | properties.map(&.name).should eq %w(a z two one a_a get_val) 550 | properties.map(&.external_name).should eq %w(a z two one a_a get_val) 551 | end 552 | end 553 | 554 | describe :alphabetical do 555 | it "should order the properties alphabetically by their name" do 556 | properties = Abc.new.serialization_properties 557 | properties.size.should eq 6 558 | 559 | properties.map(&.name).should eq %w(a a_a get_val one zzz z) 560 | properties.map(&.external_name).should eq %w(a a_a get_val one two z) 561 | end 562 | end 563 | 564 | describe :custom do 565 | it "should use the order defined by the user" do 566 | properties = Custom.new.serialization_properties 567 | properties.size.should eq 6 568 | 569 | properties.map(&.name).should eq %w(two z get_val a one a_a) 570 | properties.map(&.external_name).should eq %w(two z get_val a one a_a) 571 | end 572 | end 573 | end 574 | 575 | describe CRS::Skip do 576 | it "should not include skipped properties" do 577 | properties = Skip.new.serialization_properties 578 | properties.size.should eq 1 579 | 580 | p = properties[0] 581 | 582 | p.name.should eq "one" 583 | p.external_name.should eq "one" 584 | p.value.should eq "one" 585 | p.skip_when_empty?.should be_false 586 | p.type.should eq String 587 | p.class.should eq Skip 588 | end 589 | end 590 | 591 | describe CRS::IgnoreOnSerialize do 592 | it "should not include ignored properties" do 593 | properties = IgnoreOnSerialize.new.serialization_properties 594 | properties.size.should eq 1 595 | 596 | p = properties[0] 597 | 598 | p.name.should eq "name" 599 | p.external_name.should eq "name" 600 | p.value.should eq "Fred" 601 | p.skip_when_empty?.should be_false 602 | p.type.should eq String 603 | p.class.should eq IgnoreOnSerialize 604 | end 605 | end 606 | 607 | describe CRS::ExclusionPolicy do 608 | describe :all do 609 | describe CRS::Expose do 610 | it "should only return properties that are exposed or IgnoreOnDeserialize" do 611 | properties = Expose.new.serialization_properties 612 | properties.size.should eq 2 613 | 614 | p = properties[0] 615 | 616 | p.name.should eq "name" 617 | p.external_name.should eq "name" 618 | p.value.should eq "Jim" 619 | p.skip_when_empty?.should be_false 620 | p.type.should eq String 621 | p.class.should eq Expose 622 | 623 | p = properties[1] 624 | 625 | p.name.should eq "ignored_deserialize" 626 | p.external_name.should eq "ignored_deserialize" 627 | p.value.should eq true 628 | p.skip_when_empty?.should be_false 629 | p.type.should eq Bool 630 | p.class.should eq Expose 631 | end 632 | end 633 | end 634 | 635 | describe :none do 636 | describe CRS::Exclude do 637 | it "should only return properties that are not excluded or IgnoreOnDeserialize" do 638 | properties = Exclude.new.serialization_properties 639 | properties.size.should eq 2 640 | 641 | p = properties[0] 642 | 643 | p.name.should eq "name" 644 | p.external_name.should eq "name" 645 | p.value.should eq "Jim" 646 | p.skip_when_empty?.should be_false 647 | p.type.should eq String 648 | p.class.should eq Exclude 649 | 650 | p = properties[1] 651 | 652 | p.name.should eq "ignored_deserialize" 653 | p.external_name.should eq "ignored_deserialize" 654 | p.value.should eq true 655 | p.skip_when_empty?.should be_false 656 | p.type.should eq Bool 657 | p.class.should eq Exclude 658 | end 659 | end 660 | end 661 | end 662 | 663 | describe CRS::Name do 664 | describe :serialize do 665 | it "should use the value in the annotation or property name if it wasnt defined" do 666 | properties = SerializedName.new.serialization_properties 667 | properties.size.should eq 3 668 | 669 | p = properties[0] 670 | 671 | p.name.should eq "my_home_address" 672 | p.external_name.should eq "myAddress" 673 | p.value.should eq "123 Fake Street" 674 | p.skip_when_empty?.should be_false 675 | p.type.should eq String 676 | p.class.should eq SerializedName 677 | 678 | p = properties[1] 679 | 680 | p.name.should eq "value" 681 | p.external_name.should eq "a_value" 682 | p.value.should eq "str" 683 | p.skip_when_empty?.should be_false 684 | p.type.should eq String 685 | p.class.should eq SerializedName 686 | 687 | p = properties[2] 688 | 689 | p.name.should eq "myZipCode" 690 | p.external_name.should eq "myZipCode" 691 | p.value.should eq 90210 692 | p.skip_when_empty?.should be_false 693 | p.type.should eq Int32 694 | p.class.should eq SerializedName 695 | end 696 | end 697 | end 698 | 699 | describe CRS::SkipWhenEmpty do 700 | it "should use the value of the method" do 701 | properties = SkipWhenEmpty.new.serialization_properties 702 | properties.size.should eq 1 703 | 704 | p = properties[0] 705 | 706 | p.name.should eq "value" 707 | p.external_name.should eq "value" 708 | p.value.should eq "value" 709 | p.skip_when_empty?.should be_true 710 | p.type.should eq String 711 | p.class.should eq SkipWhenEmpty 712 | end 713 | end 714 | 715 | describe CRS::VirtualProperty do 716 | it "should only return properties that are not excluded" do 717 | properties = VirtualProperty.new.serialization_properties 718 | properties.size.should eq 2 719 | 720 | p = properties[0] 721 | 722 | p.name.should eq "foo" 723 | p.external_name.should eq "foo" 724 | p.value.should eq "foo" 725 | p.skip_when_empty?.should be_false 726 | p.type.should eq String 727 | p.class.should eq VirtualProperty 728 | 729 | p = properties[1] 730 | 731 | p.name.should eq "get_val" 732 | p.external_name.should eq "get_val" 733 | p.value.should eq "VAL" 734 | p.skip_when_empty?.should be_false 735 | p.type.should eq String 736 | p.class.should eq VirtualProperty 737 | end 738 | end 739 | 740 | describe CRS::ReadOnly do 741 | it "should include ReadOnly properties" do 742 | properties = ReadOnly.new.serialization_properties 743 | properties.size.should eq 2 744 | 745 | p = properties[0] 746 | 747 | p.name.should eq "name" 748 | p.external_name.should eq "name" 749 | 750 | p = properties[1] 751 | 752 | p.name.should eq "password" 753 | p.external_name.should eq "password" 754 | end 755 | end 756 | end 757 | end 758 | -------------------------------------------------------------------------------- /spec/exclusion_strategies/groups_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe CrSerializer::ExclusionStrategies::Groups do 4 | describe "#skip_property?" do 5 | describe "that is in the default group" do 6 | it "should not skip" do 7 | assert_groups(groups: ["default"]).should be_false 8 | end 9 | end 10 | 11 | describe "that includes at least one group" do 12 | it "should not skip" do 13 | assert_groups(groups: ["one", "two"], metadata_groups: ["two", "three"]).should be_false 14 | end 15 | end 16 | 17 | describe "that does not include any group" do 18 | it "should skip" do 19 | assert_groups(groups: ["one", "two"], metadata_groups: ["three", "four"]).should be_true 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/exclusion_strategies/version_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe CrSerializer::ExclusionStrategies::Version do 4 | describe "#skip_property?" do 5 | describe :since_version do 6 | describe "that isnt set" do 7 | it "should not skip" do 8 | assert_version.should be_false 9 | end 10 | end 11 | 12 | describe "that is less than the version" do 13 | it "should not skip" do 14 | assert_version(since_version: "0.31.0").should be_false 15 | end 16 | end 17 | 18 | describe "that is equal than the version" do 19 | it "should not skip" do 20 | assert_version(since_version: "1.0.0").should be_false 21 | end 22 | end 23 | 24 | describe "that is larger than the version" do 25 | it "should skip" do 26 | assert_version(since_version: "1.5.0").should be_true 27 | end 28 | end 29 | end 30 | 31 | describe :until_version do 32 | describe "that isnt set" do 33 | it "should not skip" do 34 | assert_version.should be_false 35 | end 36 | end 37 | 38 | describe "that is less than the version" do 39 | it "should skip" do 40 | assert_version(until_version: "0.31.0").should be_true 41 | end 42 | end 43 | 44 | describe "that is equal than the version" do 45 | it "should skip" do 46 | assert_version(until_version: "1.0.0").should be_true 47 | end 48 | end 49 | 50 | describe "that is larger than the version" do 51 | it "should not skip" do 52 | assert_version(until_version: "1.5.0").should be_false 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/formats/json_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe JSON do 4 | describe ".from_json" do 5 | describe "that is valid" do 6 | it "deserializes the given JSON string into the type" do 7 | obj = TestObject.from_json %({"some_name":"Bob"}) 8 | obj.name.should eq "Bob" 9 | obj.age.should be_nil 10 | obj.initialized.should be_true 11 | end 12 | end 13 | 14 | describe "that is missing a non-nilable property that doesn't have a default" do 15 | it "should raise the proper exception" do 16 | expect_raises CrSerializer::Exceptions::JSONParseError, "Missing json attribute: 'name'" do 17 | TestObject.from_json %({"age":123}) 18 | end 19 | end 20 | end 21 | end 22 | 23 | describe ".deserialize" do 24 | describe "invalid value" do 25 | it "should raise the proper exception" do 26 | expect_raises CrSerializer::Exceptions::JSONParseError, "Expected Bool but was Int64" do 27 | Bool.deserialize JSON, "17" 28 | end 29 | end 30 | end 31 | 32 | describe Array do 33 | describe "of a single type" do 34 | assert_deserialize_format JSON, Array(Int32), "[1,2,3]", [1, 2, 3] 35 | end 36 | 37 | describe "of a unioned type" do 38 | assert_deserialize_format JSON, Array(Int32 | String), %(["one", 17, "99"]), ["one", 17, "99"] 39 | end 40 | 41 | describe "of hashes" do 42 | hash = [{"name" => "Jim", "age" => 19}, {"name" => "Jim", "age" => 18, "value": false}] 43 | assert_deserialize_format JSON, Array(Hash(String, String | Int32 | Bool)), %([{"name":"Jim","age":19},{"name":"Jim","age":18,"value":false}]), hash 44 | end 45 | end 46 | 47 | describe Bool do 48 | assert_deserialize_format JSON, Bool, "true", true 49 | end 50 | 51 | describe Enum do 52 | describe String do 53 | assert_deserialize_format JSON, MyEnum, %("two"), MyEnum::Two 54 | end 55 | 56 | describe Int do 57 | assert_deserialize_format JSON, MyEnum, "0", MyEnum::One 58 | end 59 | end 60 | 61 | describe Hash do 62 | describe "simple hash" do 63 | hash = {"name" => "Jim", "age" => 19} 64 | assert_deserialize_format JSON, Hash(String, String | Int32), %({"name":"Jim","age":19}), hash 65 | end 66 | 67 | describe "nested hash" do 68 | hash = {"name" => "Jim", "age" => 19, "location": {"address" => "123 fake street", "zip": 90210}} 69 | assert_deserialize_format JSON, Hash(String, String | Int32 | Hash(String, String | Int32)), %({"name":"Jim","age":19,"location":{"address":"123 fake street","zip":90210}}), hash 70 | end 71 | end 72 | 73 | describe JSON::Any do 74 | assert_deserialize_format JSON, JSON::Any, "17", JSON.parse("17") 75 | end 76 | 77 | describe NamedTuple do 78 | describe "valid" do 79 | nt = {numbers: [1, 2, 3], "data": {"name" => "Jim", "age" => 19}} 80 | assert_deserialize_format JSON, NamedTuple(numbers: Array(Int32), data: Hash(String, String | Int32)), %({"numbers":[1,2,3],"data":{"name":"Jim","age":19}}), nt 81 | end 82 | 83 | describe "missing key" do 84 | it "should raise the proper error" do 85 | expect_raises CrSerializer::Exceptions::JSONParseError, "Missing json attribute: 'active'" do 86 | NamedTuple(numbers: Array(Int32), data: Hash(String, String | Int32), active: Bool).deserialize JSON, %({"numbers":[1,2,3],"data":{"name":"Jim","age":19}}) 87 | end 88 | end 89 | end 90 | end 91 | 92 | describe Nil do 93 | assert_deserialize_format JSON, Nil, "null", nil 94 | end 95 | 96 | describe Number do 97 | describe Int do 98 | describe Int8 do 99 | assert_deserialize_format JSON, Int8, "17", 17_i8 100 | end 101 | 102 | describe Int16 do 103 | assert_deserialize_format JSON, Int16, "17", 17_i16 104 | end 105 | 106 | describe Int32 do 107 | assert_deserialize_format JSON, Int32, "17", 17 108 | end 109 | 110 | describe Int64 do 111 | assert_deserialize_format JSON, Int64, "17", 17_i64 112 | end 113 | 114 | describe UInt8 do 115 | assert_deserialize_format JSON, UInt8, "17", 17_u8 116 | end 117 | 118 | describe UInt16 do 119 | assert_deserialize_format JSON, UInt16, "17", 17_u16 120 | end 121 | 122 | describe UInt32 do 123 | assert_deserialize_format JSON, UInt32, "17", 17_u32 124 | end 125 | 126 | describe UInt64 do 127 | assert_deserialize_format JSON, UInt64, "17", 17_u64 128 | end 129 | end 130 | 131 | describe Float do 132 | describe Float32 do 133 | assert_deserialize_format JSON, Float32, "17.59", 17.59_f32 134 | end 135 | 136 | describe Float64 do 137 | assert_deserialize_format JSON, Float64, "17.59", 17.59 138 | end 139 | end 140 | end 141 | 142 | describe Set do 143 | describe "of a single type" do 144 | assert_deserialize_format JSON, Set(Int32), "[1,2]", Set{1, 2} 145 | end 146 | 147 | describe "of mixed types" do 148 | assert_deserialize_format JSON, Set(Int32 | String), %(["one", 17, "99"]), Set{"one", 17, "99"} 149 | end 150 | end 151 | 152 | describe String do 153 | assert_deserialize_format JSON, String, %("foo"), "foo" 154 | end 155 | 156 | pending Slice do 157 | end 158 | 159 | pending Symbol do 160 | end 161 | 162 | describe Time do 163 | assert_deserialize_format JSON, Time, %("2016-02-15T10:20:30Z"), Time.utc(2016, 2, 15, 10, 20, 30) 164 | end 165 | 166 | describe Tuple do 167 | tup = {99, "foo", false} 168 | assert_deserialize_format JSON, Tuple(Int32, String, Bool), %([99, "foo", false]), tup 169 | end 170 | 171 | describe UUID do 172 | assert_deserialize_format JSON, UUID, %("f89dc089-2c6c-411a-af20-ea98f90376ef"), UUID.new("f89dc089-2c6c-411a-af20-ea98f90376ef") 173 | end 174 | 175 | describe Union do 176 | describe "when a value could not be parsed from the union" do 177 | it "should raise the proper exception" do 178 | expect_raises CrSerializer::Exceptions::JSONParseError, "Couldn't parse (Bool | String) from 17" do 179 | (Bool | String).deserialize JSON, "17" 180 | end 181 | end 182 | end 183 | 184 | describe "when it's possible to parse a value from the union" do 185 | assert_deserialize_format JSON, (String | Bool), "true", true 186 | end 187 | end 188 | end 189 | 190 | describe "#to_json" do 191 | it "deserializes the given JSON string into the type" do 192 | TestObject.new("Jim", 123).to_json.should eq %({"the_name":"Jim","age":123}) 193 | end 194 | end 195 | 196 | describe "#serialize" do 197 | describe Array do 198 | describe "of scalar values" do 199 | assert_serialize_format JSON, [1, 2, 3], "[1,2,3]" 200 | end 201 | 202 | describe "of mixed types" do 203 | assert_serialize_format JSON, [false, nil, "foo"], %([false,null,"foo"]) 204 | end 205 | 206 | describe "of hashes" do 207 | assert_serialize_format JSON, [{"name" => "Jim", :age => 19}, {"name" => "Jim", :age => 18, "value": false}], %([{"name":"Jim","age":19},{"name":"Jim","age":18,"value":false}]) 208 | end 209 | end 210 | 211 | describe Bool do 212 | assert_serialize_format JSON, true, "true" 213 | end 214 | 215 | describe Enum do 216 | assert_serialize_format JSON, MyEnum::Two, "1" 217 | end 218 | 219 | describe Hash do 220 | describe "simple hash" do 221 | assert_serialize_format JSON, {"name" => "Jim", :age => 19}, %({"name":"Jim","age":19}) 222 | end 223 | 224 | describe "nested hash" do 225 | assert_serialize_format JSON, {"name" => "Jim", :age => 19, "location": {"address" => "123 fake street", "zip": 90210}}, %({"name":"Jim","age":19,"location":{"address":"123 fake street","zip":90210}}) 226 | end 227 | end 228 | 229 | describe JSON::Any do 230 | assert_serialize_format JSON, JSON.parse(%({"name":"Jim","age":19})), %({"name":"Jim","age":19}) 231 | end 232 | 233 | describe NamedTuple do 234 | assert_serialize_format JSON, {numbers: [1, 2, 3], "data": {"name" => "Jim", :age => 19}}, %({"numbers":[1,2,3],"data":{"name":"Jim","age":19}}) 235 | end 236 | 237 | describe Nil do 238 | assert_serialize_format JSON, nil, "null" 239 | end 240 | 241 | describe Number do 242 | describe Int do 243 | assert_serialize_format JSON, 123, "123" 244 | end 245 | 246 | describe Float do 247 | assert_serialize_format JSON, 3.14, "3.14" 248 | end 249 | end 250 | 251 | describe Set do 252 | assert_serialize_format JSON, Set{1, 2}, "[1,2]" 253 | end 254 | 255 | describe Slice do 256 | ptr = Pointer.malloc(9) { |i| ('a'.ord + i).to_u8 } 257 | assert_serialize_format JSON, Slice.new(ptr, 3), %("YWJj\\n") 258 | end 259 | 260 | describe String do 261 | assert_serialize_format JSON, "foo", %("foo") 262 | end 263 | 264 | describe Symbol do 265 | assert_serialize_format JSON, :foo, %("foo") 266 | end 267 | 268 | describe Time do 269 | assert_serialize_format JSON, Time.utc(2016, 2, 15, 10, 20, 30), %("2016-02-15T10:20:30Z") 270 | end 271 | 272 | describe Tuple do 273 | assert_serialize_format JSON, {true, false}, "[true,false]" 274 | end 275 | 276 | describe UUID do 277 | assert_serialize_format JSON, UUID.new("f89dc089-2c6c-411a-af20-ea98f90376ef"), %("f89dc089-2c6c-411a-af20-ea98f90376ef") 278 | end 279 | 280 | describe YAML::Any do 281 | assert_serialize_format JSON, YAML.parse(%(---\nname: Jim\nage: 19)), %({"name":"Jim","age":19}) 282 | end 283 | end 284 | end 285 | -------------------------------------------------------------------------------- /spec/models/accessor.cr: -------------------------------------------------------------------------------- 1 | class Accessor 2 | include CrSerializer 3 | 4 | def initialize; end 5 | 6 | @[CRS::Accessor(getter: get_foo)] 7 | property foo : String = "foo" 8 | 9 | private def get_foo : String 10 | @foo.upcase 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/models/accessor_order.cr: -------------------------------------------------------------------------------- 1 | class Default 2 | include CrSerializer 3 | 4 | def initialize; end 5 | 6 | property a : String = "A" 7 | property z : String = "Z" 8 | property two : String = "two" 9 | property one : String = "one" 10 | property a_a : Int32 = 123 11 | 12 | @[CRS::VirtualProperty] 13 | def get_val : String 14 | "VAL" 15 | end 16 | end 17 | 18 | @[CRS::AccessorOrder(:alphabetical)] 19 | class Abc 20 | include CrSerializer 21 | 22 | def initialize; end 23 | 24 | property a : String = "A" 25 | property z : String = "Z" 26 | property one : String = "one" 27 | property a_a : Int32 = 123 28 | 29 | @[CRS::Name(serialize: "two")] 30 | property zzz : String = "two" 31 | 32 | @[CRS::VirtualProperty] 33 | def get_val : String 34 | "VAL" 35 | end 36 | end 37 | 38 | @[CRS::AccessorOrder(:custom, order: ["two", "z", "get_val", "a", "one", "a_a"])] 39 | class Custom 40 | include CrSerializer 41 | 42 | def initialize; end 43 | 44 | property a : String = "A" 45 | property z : String = "Z" 46 | property two : String = "two" 47 | property one : String = "one" 48 | property a_a : Int32 = 123 49 | 50 | @[CRS::VirtualProperty] 51 | def get_val : String 52 | "VAL" 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/models/emit_null.cr: -------------------------------------------------------------------------------- 1 | class EmitNil 2 | include CrSerializer 3 | 4 | def initialize; end 5 | 6 | property name : String? 7 | property age : Int32 = 1 8 | end 9 | -------------------------------------------------------------------------------- /spec/models/exclude.cr: -------------------------------------------------------------------------------- 1 | @[CRS::ExclusionPolicy(:none)] 2 | class Exclude 3 | include CrSerializer 4 | 5 | def initialize; end 6 | 7 | property name : String = "Jim" 8 | 9 | @[CRS::Exclude] 10 | property password : String? = "monkey" 11 | 12 | @[CRS::IgnoreOnSerialize] 13 | property ignored_serialize : Bool = false 14 | 15 | @[CRS::IgnoreOnDeserialize] 16 | property ignored_deserialize : Bool = true 17 | end 18 | -------------------------------------------------------------------------------- /spec/models/expose.cr: -------------------------------------------------------------------------------- 1 | @[CRS::ExclusionPolicy(:all)] 2 | class Expose 3 | include CrSerializer 4 | 5 | def initialize; end 6 | 7 | @[CRS::Expose] 8 | property name : String = "Jim" 9 | 10 | property password : String? = "monkey" 11 | 12 | @[CRS::IgnoreOnSerialize] 13 | property ignored_serialize : Bool = false 14 | 15 | @[CRS::IgnoreOnDeserialize] 16 | property ignored_deserialize : Bool = true 17 | end 18 | -------------------------------------------------------------------------------- /spec/models/groups.cr: -------------------------------------------------------------------------------- 1 | class Group 2 | include CrSerializer 3 | 4 | def initialize; end 5 | 6 | @[CRS::Groups("list", "details")] 7 | property id : Int64 = 1 8 | 9 | @[CRS::Groups("list")] 10 | property comment_summaries : Array(String) = ["Sentence 1.", "Sentence 2."] 11 | 12 | @[CRS::Groups("details")] 13 | property comments : Array(String) = ["Sentence 1. Another sentence.", "Sentence 2. Some other stuff."] 14 | 15 | property created_at : Time = Time.utc(2019, 1, 1) 16 | end 17 | -------------------------------------------------------------------------------- /spec/models/ignore_on_deserialize.cr: -------------------------------------------------------------------------------- 1 | class IgnoreOnDeserialize 2 | include CrSerializer 3 | 4 | def initialize; end 5 | 6 | property name : String = "Fred" 7 | 8 | @[CRS::IgnoreOnDeserialize] 9 | property password : String = "monkey" 10 | end 11 | -------------------------------------------------------------------------------- /spec/models/ignore_on_serialize.cr: -------------------------------------------------------------------------------- 1 | class IgnoreOnSerialize 2 | include CrSerializer 3 | 4 | def initialize; end 5 | 6 | property name : String = "Fred" 7 | 8 | @[CRS::IgnoreOnSerialize] 9 | property password : String = "monkey" 10 | end 11 | -------------------------------------------------------------------------------- /spec/models/name.cr: -------------------------------------------------------------------------------- 1 | class SerializedName 2 | include CrSerializer 3 | 4 | def initialize; end 5 | 6 | @[CRS::Name(serialize: "myAddress")] 7 | property my_home_address : String = "123 Fake Street" 8 | 9 | @[CRS::Name(deserialize: "some_key", serialize: "a_value")] 10 | property value : String = "str" 11 | 12 | # ameba:disable Style/VariableNames 13 | property myZipCode : Int32 = 90210 14 | end 15 | 16 | class DeserializedName 17 | include CrSerializer 18 | 19 | def initialize; end 20 | 21 | @[CRS::Name(deserialize: "des")] 22 | property custom_name : Int32? 23 | 24 | property default_name : Bool? 25 | end 26 | 27 | class AliasName 28 | include CrSerializer 29 | 30 | def initialize; end 31 | 32 | @[CRS::Name(aliases: ["val", "value", "some_value"])] 33 | property some_value : String? 34 | end 35 | -------------------------------------------------------------------------------- /spec/models/post_deserialize.cr: -------------------------------------------------------------------------------- 1 | @[CRS::ExclusionPolicy(:all)] 2 | class PostDeserialize 3 | include CrSerializer 4 | 5 | def initialize; end 6 | 7 | getter first_name : String? 8 | getter last_name : String? 9 | 10 | @[CRS::Expose] 11 | getter name : String = "First Last" 12 | 13 | @[CRS::PostDeserialize] 14 | def split_name : Nil 15 | @first_name, @last_name = @name.split(' ') 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/models/post_serialize.cr: -------------------------------------------------------------------------------- 1 | class PostSerialize 2 | include CrSerializer 3 | 4 | def initialize; end 5 | 6 | getter name : String? 7 | getter age : Int32? 8 | 9 | @[CRS::PreSerialize] 10 | def set_name : Nil 11 | @name = "NAME" 12 | end 13 | 14 | @[CRS::PreSerialize] 15 | def set_age : Nil 16 | @age = 123 17 | end 18 | 19 | @[CRS::PostSerialize] 20 | def reset : Nil 21 | @age = nil 22 | @name = nil 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/models/pre_serialize.cr: -------------------------------------------------------------------------------- 1 | class PreSerialize 2 | include CrSerializer 3 | 4 | def initialize; end 5 | 6 | getter name : String? 7 | getter age : Int32? 8 | 9 | @[CRS::PreSerialize] 10 | def set_name : Nil 11 | @name = "NAME" 12 | end 13 | 14 | @[CRS::PreSerialize] 15 | def set_age : Nil 16 | @age = 123 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/models/read_only.cr: -------------------------------------------------------------------------------- 1 | class ReadOnly 2 | include CrSerializer 3 | 4 | def initialize; end 5 | 6 | property name : String = "name" 7 | 8 | @[CRS::ReadOnly] 9 | property password : String? = nil 10 | end 11 | -------------------------------------------------------------------------------- /spec/models/skip.cr: -------------------------------------------------------------------------------- 1 | class Skip 2 | include CrSerializer 3 | 4 | def initialize; end 5 | 6 | property one : String = "one" 7 | 8 | @[CRS::Skip] 9 | property two : String = "two" 10 | end 11 | -------------------------------------------------------------------------------- /spec/models/skip_when_empty.cr: -------------------------------------------------------------------------------- 1 | class SkipWhenEmpty 2 | include CrSerializer 3 | 4 | def initialize; end 5 | 6 | @[CRS::SkipWhenEmpty] 7 | property value : String = "value" 8 | end 9 | -------------------------------------------------------------------------------- /spec/models/virtual_property.cr: -------------------------------------------------------------------------------- 1 | class VirtualProperty 2 | include CrSerializer 3 | 4 | def initialize; end 5 | 6 | property foo : String = "foo" 7 | 8 | @[CRS::VirtualProperty] 9 | def get_val : String 10 | "VAL" 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/serialization_context_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | struct False < CrSerializer::ExclusionStrategies::ExclusionStrategy 4 | def initialize; end 5 | 6 | # :inherit: 7 | def skip_property?(metadata : CrSerializer::PropertyMetadata, context : CrSerializer::Context) : Bool 8 | false 9 | end 10 | end 11 | 12 | describe CrSerializer::SerializationContext do 13 | describe "#add_exclusion_strategy" do 14 | describe "with no previous strategy" do 15 | it "should set it directly" do 16 | context = CrSerializer::SerializationContext.new 17 | context.exclusion_strategy.should be_nil 18 | 19 | context.add_exclusion_strategy False.new 20 | 21 | context.exclusion_strategy.should be_a False 22 | end 23 | end 24 | 25 | describe "with a strategy already set" do 26 | it "should use a Disjunct strategy" do 27 | context = CrSerializer::SerializationContext.new 28 | context.exclusion_strategy.should be_nil 29 | 30 | context.add_exclusion_strategy False.new 31 | context.add_exclusion_strategy False.new 32 | 33 | context.exclusion_strategy.should be_a CrSerializer::ExclusionStrategies::Disjunct 34 | context.exclusion_strategy.try &.as(CrSerializer::ExclusionStrategies::Disjunct).members.size.should eq 2 35 | end 36 | end 37 | 38 | describe "with a multiple strategies already set" do 39 | it "should push the member to the Disjunct strategy" do 40 | context = CrSerializer::SerializationContext.new 41 | context.exclusion_strategy.should be_nil 42 | 43 | context.add_exclusion_strategy False.new 44 | context.add_exclusion_strategy False.new 45 | context.add_exclusion_strategy False.new 46 | 47 | context.exclusion_strategy.should be_a CrSerializer::ExclusionStrategies::Disjunct 48 | context.exclusion_strategy.try &.as(CrSerializer::ExclusionStrategies::Disjunct).members.size.should eq 3 49 | end 50 | end 51 | end 52 | 53 | describe "#groups=" do 54 | it "sets the groups" do 55 | context = CrSerializer::SerializationContext.new.groups = ["one", "two"] 56 | context.groups.should eq ["one", "two"] 57 | end 58 | 59 | it "raises if the groups are empty" do 60 | expect_raises ArgumentError, "Groups cannot be empty" do 61 | CrSerializer::SerializationContext.new.groups = [] of String 62 | end 63 | end 64 | end 65 | 66 | describe "#version=" do 67 | it "sets the version as a `SemanticVersion`" do 68 | context = CrSerializer::SerializationContext.new.version = "1.1.1" 69 | context.version.should eq SemanticVersion.new 1, 1, 1 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/CrSerializer" 3 | require "./models/*" 4 | 5 | private DEFAULT_PROC = ->(_properties : Array(CrSerializer::Metadata), _context : CrSerializer::Context) {} 6 | 7 | enum MyEnum 8 | One 9 | Two 10 | end 11 | 12 | class SomeObj 13 | end 14 | 15 | class TestObject 16 | include CrSerializer 17 | 18 | def initialize(@name : String, @age : Int32?); end 19 | 20 | @[CRS::Name(serialize: "the_name", deserialize: "some_name")] 21 | getter name : String 22 | getter age : Int32? 23 | 24 | @[CRS::Skip] 25 | getter initialized : Bool = false 26 | 27 | @[CRS::PostDeserialize] 28 | def set_initialized : Nil 29 | @initialized = true 30 | end 31 | end 32 | 33 | module CrSerializer 34 | def serialization_properties 35 | previous_def 36 | end 37 | end 38 | 39 | # Test module for format agnostic testing of serialization/deserialization features. 40 | # 41 | # Can define a proc that will yield the properties and context passed to `.serialize`. 42 | module TEST 43 | include CrSerializer::Format 44 | 45 | class_setter assert_properties : Proc(Array(CrSerializer::Metadata), CrSerializer::Context, Nil) = DEFAULT_PROC 46 | 47 | def self.deserialize(type : _, properties : Array(CrSerializer::Metadata), string_or_io : String | IO, context : CrSerializer::Context) 48 | @@assert_properties.call properties, context 49 | type.new 50 | end 51 | 52 | def self.serialize(properties : Array(CrSerializer::Metadata), context : CrSerializer::Context) : String 53 | @@assert_properties.call properties, context 54 | "" 55 | end 56 | end 57 | 58 | macro assert_deserialize_format(format, type, input, output) 59 | it "should serialize correctly" do 60 | {{type}}.deserialize({{format}}, {{input}}).should eq {{output}} 61 | end 62 | end 63 | 64 | macro assert_serialize_format(format, input, output) 65 | it "should serialize correctly" do 66 | {{input}}.serialize({{format}}).should eq {{output}} 67 | end 68 | end 69 | 70 | def create_metadata(*, name : String = "name", external_name : String = "external_name", value : I = "value", skip_when_empty : Bool = false, groups : Array(String) = ["default"], since_version : String? = nil, until_version : String? = nil) : CrSerializer::PropertyMetadata forall I 71 | context = CrSerializer::PropertyMetadata(I, SomeObj).new name, external_name, value, skip_when_empty, groups 72 | 73 | context.since_version = SemanticVersion.parse since_version if since_version 74 | context.until_version = SemanticVersion.parse until_version if until_version 75 | 76 | context 77 | end 78 | 79 | def assert_version(*, since_version : String? = nil, until_version : String? = nil) : Bool 80 | CrSerializer::ExclusionStrategies::Version.new(SemanticVersion.parse "1.0.0").skip_property?(create_metadata(since_version: since_version, until_version: until_version), CrSerializer::SerializationContext.new) 81 | end 82 | 83 | def assert_groups(*, groups : Array(String), metadata_groups : Array(String) = ["default"]) : Bool 84 | CrSerializer::ExclusionStrategies::Groups.new(groups).skip_property?(create_metadata(groups: metadata_groups), CrSerializer::SerializationContext.new) 85 | end 86 | 87 | Spec.before_each { TEST.assert_properties = DEFAULT_PROC } 88 | -------------------------------------------------------------------------------- /src/CrSerializer.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "yaml" 3 | require "semantic_version" 4 | 5 | # Can be included into a module to register that 6 | # module as a serialization format. 7 | # 8 | # The including module would have to implement the required logic 9 | # for handling the process of serializing and deserializing the data. 10 | # ``` 11 | # module CustomFormat 12 | # include CrSerializer::Format 13 | # end 14 | # 15 | # some_obj.serialize CustomFormat 16 | # SomeType.deserialize CustomFormat, input_string_or_io 17 | # ``` 18 | module CrSerializer::Format 19 | end 20 | 21 | require "./exceptions/*" 22 | require "./exclusion_strategies/*" 23 | require "./annotations" 24 | require "./deserialization_context" 25 | require "./serialization_context" 26 | require "./property_metadata" 27 | require "./exclusion_policy" 28 | require "./formats/*" 29 | 30 | # Shorthand alias to the `CrSerializer::Annotations` module. 31 | # 32 | # ``` 33 | # @[CRS::Expose] 34 | # @[CRS::Groups("detail", "list")] 35 | # property title : String 36 | # ``` 37 | alias CRS = CrSerializer::Annotations 38 | 39 | # Annotation based serialization/deserialization library. 40 | # 41 | # ## Features 42 | # * Options are defined on ivars, no custom DSL. 43 | # * Can be used in conjunction with other shards, such as ORMs, as long as they use properties and allow adding annotations. 44 | # * `*::Serializable` compatible API. 45 | # 46 | # ## Concepts 47 | # * `CrSerializer::Annotations` - Used to control how a property gets serialized/deserialized. 48 | # * `CrSerializer::ExclusionStrategies` - Determines which properties within a class/struct should be serialized and deserialized. Custom strategies can be defined. 49 | # * `CrSerializer::Context` - Represents runtime data about the current serialization/deserialization action. Can be reopened to add custom data. 50 | # * `CrSerializer::Format` - Represents a valid serialization/deserialization format. Can be included into a module to register a custom format. 51 | # 52 | # ## Example Usage 53 | # ``` 54 | # @[CRS::ExclusionPolicy(:all)] 55 | # @[CRS::AccessorOrder(:alphabetical)] 56 | # class Example 57 | # include CrSerializer 58 | # 59 | # @[CRS::Expose] 60 | # @[CRS::Groups("details")] 61 | # property name : String 62 | # 63 | # @[CRS::Expose] 64 | # @[CRS::Name(deserialize: "a_prop", serialize: "a_prop")] 65 | # property some_prop : String 66 | # 67 | # @[CRS::Expose] 68 | # @[CRS::Groups("default", "details")] 69 | # @[CRS::Accessor(getter: get_title)] 70 | # property title : String 71 | # 72 | # @[CRS::ReadOnly] 73 | # property password : String? 74 | # 75 | # getter first_name : String? 76 | # getter last_name : String? 77 | # 78 | # @[CRS::PostDeserialize] 79 | # def split_name : Nil 80 | # @first_name, @last_name = @name.split(' ') 81 | # end 82 | # 83 | # @[CRS::VirtualProperty] 84 | # def get_val : String 85 | # "VAL" 86 | # end 87 | # 88 | # private def get_title : String 89 | # @title.downcase 90 | # end 91 | # end 92 | # 93 | # obj = Example.from_json %({"name":"FIRST LAST","a_prop":"STR","title":"TITLE","password":"monkey123"}) 94 | # obj.inspect # => # 95 | # obj.to_json # => {"a_prop":"STR","get_val":"VAL","name":"FIRST LAST","title":"title"} 96 | # obj.to_json CrSerializer::SerializationContext.new.groups = ["details"] # => {"name":"FIRST LAST","title":"title"} 97 | # ``` 98 | module CrSerializer 99 | macro included 100 | def self.deserialize(format : CrSerializer::Format.class, string_or_io : String | IO, context : CrSerializer::DeserializationContext = CrSerializer::DeserializationContext.new) : self 101 | 102 | # Initialize the context. Currently just used to apply default exclusion strategies 103 | context.init 104 | 105 | properties = self.deserialization_properties 106 | 107 | # Apply exclusion strategies if one is defined 108 | if strategy = context.exclusion_strategy 109 | properties.reject! { |property| strategy.skip_property? property, context } 110 | end 111 | 112 | # Get the serialized output for the set of properties 113 | obj = format.deserialize \{{@type}}, properties , string_or_io, context 114 | 115 | # Run any post deserialization methods 116 | \{% for method in @type.methods.select { |m| m.annotation(CRS::PostDeserialize) } %} 117 | obj.\{{method.name}} 118 | \{% end %} 119 | 120 | obj 121 | end 122 | 123 | def self.deserialization_properties : Array(CrSerializer::Metadata) 124 | {% verbatim do %} 125 | {% begin %} 126 | # Construct the array of metadata from the properties on `self`. 127 | # Takes into consideration some annotations to control how/when a property should be serialized 128 | {% 129 | ivars = @type.instance_vars 130 | .reject { |ivar| ivar.annotation(CRS::Skip) } 131 | .reject { |ivar| ivar.annotation(CRS::IgnoreOnDeserialize) } 132 | .reject { |ivar| (ann = ivar.annotation(CRS::ReadOnly)); ann && !ivar.has_default_value? && !ivar.type.nilable? ? raise "#{@type}##{ivar.name} is read-only but is not nilable nor has a default value" : ann } 133 | .reject do |ivar| 134 | not_exposed = (ann = @type.annotation(CRS::ExclusionPolicy)) && ann[0] == :all && !ivar.annotation(CRS::Expose) 135 | excluded = (ann = @type.annotation(CRS::ExclusionPolicy)) && ann[0] == :none && ivar.annotation(CRS::Exclude) 136 | 137 | !ivar.annotation(CRS::IgnoreOnSerialize) && (not_exposed || excluded) 138 | end 139 | %} 140 | 141 | {{ivars.map do |ivar| 142 | %(CrSerializer::PropertyMetadata(#{ivar.type}?, #{@type}) 143 | .new( 144 | name: #{ivar.name.stringify}, 145 | external_name: #{(ann = ivar.annotation(CRS::Name)) && (name = ann[:deserialize]) ? name : ivar.name.stringify}, 146 | aliases: #{(ann = ivar.annotation(CRS::Name)) && (aliases = ann[:aliases]) ? aliases : "[] of String".id}, 147 | groups: #{(ann = ivar.annotation(CRS::Groups)) && !ann.args.empty? ? [ann.args.splat] : ["default"]}, 148 | since_version: #{(ann = ivar.annotation(CRS::Since)) && !ann[0].nil? ? "SemanticVersion.parse(#{ann[0]})".id : nil}, 149 | until_version: #{(ann = ivar.annotation(CRS::Until)) && !ann[0].nil? ? "SemanticVersion.parse(#{ann[0]})".id : nil}, 150 | )).id 151 | end}} of CrSerializer::Metadata 152 | {% end %} 153 | {% end %} 154 | end 155 | end 156 | 157 | # Deserializes the given *string_or_io* into `self` from the given *format*, optionally with the given *context*. 158 | # 159 | # NOTE: This method is defined within a macro included hook. This definition is simply for documentation. 160 | def self.deserialize(format : CrSerializer::Format.class, string_or_io : String | IO, context : CrSerializer::DeserializationContext = CrSerializer::DeserializationContext.new) : self 161 | end 162 | 163 | # Serializes `self` into the given *format*, optionally with the given *context*. 164 | def serialize(format : CrSerializer::Format.class, context : CrSerializer::SerializationContext = CrSerializer::SerializationContext.new) : String 165 | {% begin %} 166 | 167 | # Initialize the context. Currently just used to apply default exclusion strategies 168 | context.init 169 | 170 | # Run any pre serialization methods 171 | {% for method in @type.methods.select { |m| m.annotation(CRS::PreSerialize) } %} 172 | {{method.name}} 173 | {% end %} 174 | 175 | properties = serialization_properties 176 | 177 | # Apply exclusion strategies if one is defined 178 | if strategy = context.exclusion_strategy 179 | properties.reject! { |property| strategy.skip_property? property, context } 180 | end 181 | 182 | # Reject properties that shoud be skipped when empty 183 | # or properties that should be skipped when nil 184 | properties.reject! do |property| 185 | val = property.value 186 | skip_when_empty = property.skip_when_empty? && val.responds_to? :empty? && val.empty? 187 | skip_nil = !context.emit_nil? && val.nil? 188 | 189 | skip_when_empty || skip_nil 190 | end 191 | 192 | # Get the serialized output for the set of properties 193 | output = format.serialize properties, context 194 | 195 | # Run any post serialization methods 196 | {% for method in @type.methods.select { |m| m.annotation(CRS::PostSerialize) } %} 197 | {{method.name}} 198 | {% end %} 199 | 200 | # Return the serialized data 201 | output 202 | {% end %} 203 | end 204 | 205 | # The `PropertyMetadata` that makes up `self`'s properties. 206 | protected def serialization_properties : Array(CrSerializer::Metadata) 207 | {% begin %} 208 | # Construct the array of metadata from the properties on `self`. 209 | # Takes into consideration some annotations to control how/when a property should be serialized 210 | {% 211 | ivars = @type.instance_vars 212 | .reject { |ivar| ivar.annotation(CRS::Skip) } 213 | .reject { |ivar| ivar.annotation(CRS::IgnoreOnSerialize) } 214 | .reject do |ivar| 215 | not_exposed = (ann = @type.annotation(CRS::ExclusionPolicy)) && ann[0] == :all && !ivar.annotation(CRS::Expose) 216 | excluded = (ann = @type.annotation(CRS::ExclusionPolicy)) && ann[0] == :none && ivar.annotation(CRS::Exclude) 217 | 218 | !ivar.annotation(CRS::IgnoreOnDeserialize) && (not_exposed || excluded) 219 | end 220 | %} 221 | 222 | {% property_hash = {} of String => CrSerializer::PropertyMetadata %} 223 | 224 | {% for ivar in ivars %} 225 | {% external_name = (ann = ivar.annotation(CRS::Name)) && (name = ann[:serialize]) ? name : ivar.name.stringify %} 226 | 227 | {% property_hash[external_name] = %(CrSerializer::PropertyMetadata( 228 | #{ivar.type}, 229 | #{@type}, 230 | ) 231 | .new( 232 | name: #{ivar.name.stringify}, 233 | external_name: #{external_name}, 234 | value: #{(accessor = ivar.annotation(CRS::Accessor)) && accessor[:getter] != nil ? accessor[:getter].id : ivar.id}, 235 | skip_when_empty: #{!!ivar.annotation(CRS::SkipWhenEmpty)}, 236 | groups: #{(ann = ivar.annotation(CRS::Groups)) && !ann.args.empty? ? [ann.args.splat] : ["default"]}, 237 | since_version: #{(ann = ivar.annotation(CRS::Since)) && !ann[0].nil? ? "SemanticVersion.parse(#{ann[0]})".id : nil}, 238 | until_version: #{(ann = ivar.annotation(CRS::Until)) && !ann[0].nil? ? "SemanticVersion.parse(#{ann[0]})".id : nil}, 239 | )).id %} 240 | {% end %} 241 | 242 | {% for method in @type.methods.select { |method| method.annotation(CRS::VirtualProperty) } %} 243 | {% external_name = (ann = method.annotation(CRS::Name)) && (name = ann[:serialize]) ? name : method.name.stringify %} 244 | 245 | {% property_hash[external_name] = %(CrSerializer::PropertyMetadata( 246 | #{method.return_type}, 247 | #{@type}, 248 | ) 249 | .new( 250 | name: #{method.name.stringify}, 251 | external_name: #{external_name}, 252 | value: #{method.name.id}, 253 | skip_when_empty: #{!!method.annotation(CRS::SkipWhenEmpty)}, 254 | )).id %} 255 | {% end %} 256 | 257 | {% if (ann = @type.annotation(CRS::AccessorOrder)) && !ann[0].nil? %} 258 | {% if ann[0] == :alphabetical %} 259 | {% properties = property_hash.keys.sort.map { |key| property_hash[key] } %} 260 | {% elsif ann[0] == :custom && !ann[:order].nil? %} 261 | {% raise "Not all properties were defined in the custom order for '#{@type}'" unless property_hash.keys.all? { |prop| ann[:order].map(&.id.stringify).includes? prop } %} 262 | {% properties = ann[:order].map { |val| property_hash[val.id.stringify] || raise "Unknown instance variable: '#{val.id}'" } %} 263 | {% else %} 264 | {% raise "Invalid CRS::AccessorOrder value: '#{ann[0].id}'" %} 265 | {% end %} 266 | {% else %} 267 | {% properties = property_hash.values %} 268 | {% end %} 269 | 270 | {{properties}} of CrSerializer::Metadata 271 | {% end %} 272 | end 273 | end 274 | -------------------------------------------------------------------------------- /src/annotations.cr: -------------------------------------------------------------------------------- 1 | # [CrSerializer](./index.html) uses annotations to control how an object gets serialized and deserialized. 2 | # This module includes all the default serialization and deserialization annotations. The `CRS` alias can be used as a shorthand when applying the annotations. 3 | module CrSerializer::Annotations 4 | # Defines the method to use to get/set the property's value. 5 | # 6 | # TODO: Implement `setter`. 7 | # 8 | # ``` 9 | # class Example 10 | # include CrSerializer 11 | # 12 | # def initialize; end 13 | # 14 | # @[CRS::Accessor(getter: get_foo)] 15 | # property foo : String = "foo" 16 | # 17 | # private def get_foo : String 18 | # @foo.upcase 19 | # end 20 | # end 21 | # 22 | # Example.new.serialize JSON # => {"foo":"FOO"} 23 | # ``` 24 | annotation Accessor; end 25 | 26 | # Defines the order of properties within a class/struct. Valid values: `:alphabetical`, and `:custom`. 27 | # 28 | # By default properties are ordered in the order in which they were defined. 29 | # ``` 30 | # class Default 31 | # include CrSerializer 32 | # 33 | # def initialize; end 34 | # 35 | # property a : String = "A" 36 | # property z : String = "Z" 37 | # property two : String = "two" 38 | # property one : String = "one" 39 | # property a_a : Int32 = 123 40 | # 41 | # @[CRS::VirtualProperty] 42 | # def get_val : String 43 | # "VAL" 44 | # end 45 | # end 46 | # 47 | # Default.new.to_json # => {"a":"A","z":"Z","two":"two","one":"one","a_a":123,"get_val":"VAL"} 48 | # 49 | # @[CRS::AccessorOrder(:alphabetical)] 50 | # class Abc 51 | # include CrSerializer 52 | # 53 | # def initialize; end 54 | # 55 | # property a : String = "A" 56 | # property z : String = "Z" 57 | # property two : String = "two" 58 | # property one : String = "one" 59 | # property a_a : Int32 = 123 60 | # 61 | # @[CRS::VirtualProperty] 62 | # def get_val : String 63 | # "VAL" 64 | # end 65 | # end 66 | # 67 | # Abc.new.to_json # => {"a":"A","a_a":123,"get_val":"VAL","one":"one","two":"two","z":"Z"} 68 | # 69 | # @[CRS::AccessorOrder(:custom, order: ["two", "z", "get_val", "a", "one", "a_a"])] 70 | # class Custom 71 | # include CrSerializer 72 | # 73 | # def initialize; end 74 | # 75 | # property a : String = "A" 76 | # property z : String = "Z" 77 | # property two : String = "two" 78 | # property one : String = "one" 79 | # property a_a : Int32 = 123 80 | # 81 | # @[CRS::VirtualProperty] 82 | # def get_val : String 83 | # "VAL" 84 | # end 85 | # end 86 | # 87 | # Custom.new.to_json # => {"two":"two","z":"Z","get_val":"VAL","a":"A","one":"one","a_a":123} 88 | # ``` 89 | annotation AccessorOrder; end 90 | 91 | # TODO: Implement this. 92 | annotation Discriminator; end 93 | 94 | # Indicates that a property should not be serialized/deserialized when used with `CrSerializer::ExclusionPolicy::None`. 95 | # 96 | # Also see, `CRS::IgnoreOnDeserialize` and `CRS::IgnoreOnSerialize`. 97 | # ``` 98 | # @[CRS::ExclusionPolicy(:none)] 99 | # class Example 100 | # include CrSerializer 101 | # 102 | # def initialize; end 103 | # 104 | # property name : String = "Jim" 105 | # 106 | # @[CRS::Exclude] 107 | # property password : String? = "monkey" 108 | # end 109 | # 110 | # Example.new.to_json # => {"name":"Jim"} 111 | # ``` 112 | annotation Exclude; end 113 | 114 | # Defines the default exclusion policy to use on a class. Valid values: `:none`, and `:all`. 115 | # 116 | # Used with `CRS::Expose` and `CRS::Exclude`. 117 | # 118 | # See`CrSerializer::ExclusionPolicy`. 119 | annotation ExclusionPolicy; end 120 | 121 | # Indicates that a property should be serialized/deserialized when used with `CrSerializer::ExclusionPolicy::All`. 122 | # 123 | # ``` 124 | # @[CRS::ExclusionPolicy(:all)] 125 | # class Example 126 | # include CrSerializer 127 | # 128 | # def initialize; end 129 | # 130 | # @[CRS::Expose] 131 | # property name : String = "Jim" 132 | # 133 | # property password : String? = "monkey" 134 | # end 135 | # 136 | # Example.new.to_json # => {"name":"Jim"} 137 | # ``` 138 | annotation Expose; end 139 | 140 | # Defines the group(s) a property belongs to. Properties are automatically added to the `default` group 141 | # if no groups are explicitly defined. 142 | # 143 | # See `CrSerializer::ExclusionStrategies::Groups`. 144 | annotation Groups; end 145 | 146 | # Indicates that a property should not be set on deserialization, but should be serialized. 147 | # 148 | # ``` 149 | # class Example 150 | # include CrSerializer 151 | # 152 | # def initialize; end 153 | # 154 | # property name : String 155 | # 156 | # @[CRS::IgnoreOnDeserialize] 157 | # property password : String? 158 | # end 159 | # 160 | # obj = Example.deserialize %({"name":"Jim","password":"monkey123"}) 161 | # 162 | # obj.password # => nil 163 | # 164 | # obj.password = "foobar" 165 | # 166 | # obj.to_json # => {"name":"Jim","password":"foobar"} 167 | # ``` 168 | annotation IgnoreOnDeserialize; end 169 | 170 | # Indicates that a property should be set on deserialization, but should not be serialized. 171 | # 172 | # ``` 173 | # class Example 174 | # include CrSerializer 175 | # 176 | # def initialize; end 177 | # 178 | # property name : String 179 | # 180 | # @[CRS::IgnoreOnSerialize] 181 | # property password : String 182 | # end 183 | # 184 | # obj = Example.from_json %({"name":"Jim","password":"monkey123"}) 185 | # 186 | # obj.password # => "monkey123" 187 | # 188 | # obj.to_json # => {"name":"Jim"} 189 | # ``` 190 | annotation IgnoreOnSerialize; end 191 | 192 | # Defines a callback method(s) that are ran directly before the object is serialized. 193 | # 194 | # ``` 195 | # @[CRS::ExclusionPolicy(:all)] 196 | # class Example 197 | # include CrSerializer 198 | # 199 | # def initialize; end 200 | # 201 | # @[CRS::Expose] 202 | # private getter name : String? 203 | # 204 | # property first_name : String = "Jon" 205 | # property last_name : String = "Snow" 206 | # 207 | # @[CRS::PreSerialize] 208 | # def pre_ser : Nil 209 | # @name = "#{first_name} #{last_name}" 210 | # end 211 | # 212 | # @[CRS::PostSerialize] 213 | # def post_ser : Nil 214 | # @name = nil 215 | # end 216 | # end 217 | # 218 | # Example.new.to_json # => {"name":"Jon Snow"} 219 | # ``` 220 | annotation PreSerialize; end 221 | 222 | # Defines a callback method(s) that are ran directly after the object has been serialized. 223 | # 224 | # ``` 225 | # @[CRS::ExclusionPolicy(:all)] 226 | # class Example 227 | # include CrSerializer 228 | # 229 | # def initialize; end 230 | # 231 | # @[CRS::Expose] 232 | # private getter name : String? 233 | # 234 | # property first_name : String = "Jon" 235 | # property last_name : String = "Snow" 236 | # 237 | # @[CRS::PreSerialize] 238 | # def pre_ser : Nil 239 | # @name = "#{first_name} #{last_name}" 240 | # end 241 | # 242 | # @[CRS::PostSerialize] 243 | # def post_ser : Nil 244 | # @name = nil 245 | # end 246 | # end 247 | # 248 | # Example.new.to_json # => {"name":"Jon Snow"} 249 | # ``` 250 | annotation PostSerialize; end 251 | 252 | # Defines a callback method(s) that are ran directly after the object has been deserialized. 253 | # 254 | # ``` 255 | # record Example, name : String, first_name : String?, last_name : String? do 256 | # include CrSerializer 257 | # 258 | # @[CRS::PostDeserialize] 259 | # def split_name : Nil 260 | # @first_name, @last_name = @name.split(' ') 261 | # end 262 | # end 263 | # 264 | # obj = Example.deserialize JSON, %({"name":"Jon Snow"}) 265 | # obj.name # => Jon Snow 266 | # obj.first_name # => Jon 267 | # obj.last_name # => Snow 268 | # ``` 269 | annotation PostDeserialize; end 270 | 271 | # Indicates that a property is read-only and cannot be set during deserialization. 272 | # 273 | # NOTE: The property must be nilable or have a default value. 274 | # ``` 275 | # class ReadOnly 276 | # include CrSerializer 277 | # 278 | # property name : String 279 | # 280 | # @[CRS::ReadOnly] 281 | # property password : String? 282 | # end 283 | # 284 | # obj = ReadOnly.from_json %({"name":"Fred","password":"password1"}) 285 | # obj.name # => "Fred" 286 | # obj.password # => nil 287 | # ``` 288 | annotation ReadOnly; end 289 | 290 | # Defines the name to use on deserialization and serialization. If not provided, the name defaults to the name of the property. 291 | # Also allows defining aliases that can be used for that property when deserializing. 292 | # 293 | # ``` 294 | # class Example 295 | # include CrSerializer 296 | # 297 | # def initialize; end 298 | # 299 | # @[CRS::Name(serialize: "myAddress")] 300 | # property my_home_address : String = "123 Fake Street" 301 | # 302 | # @[CRS::Name(deserialize: "some_key", serialize: "a_value")] 303 | # property both_names : String = "str" 304 | # 305 | # @[CRS::Name(aliases: ["val", "value", "some_value"])] 306 | # property some_value : String? = "some_val" 307 | # end 308 | # 309 | # Example.new.to_json # => {"myAddress":"123 Fake Street","a_value":"str","some_value":"some_val"} 310 | # obj = Example.from_json %({"my_home_address":"555 Mason Ave","some_key":"deserialized from diff key","value":"some_other_val"}) 311 | # obj.my_home_address # => "555 Mason Ave" 312 | # obj.both_names # => "deserialized from diff key" 313 | # obj.some_value # => "some_other_val" 314 | # ``` 315 | annotation Name; end 316 | 317 | # Represents the first version a property was available. 318 | # 319 | # See `CrSerializer::ExclusionStrategies::Version`. 320 | # NOTE: Value must be a `SemanticVersion` version. 321 | annotation Since; end 322 | 323 | # Indicates that a property should not be serialized or deserialized. 324 | # 325 | # ``` 326 | # class Example 327 | # include CrSerializer 328 | # 329 | # def initialize; end 330 | # 331 | # property name : String = "Jim" 332 | # 333 | # @[CRS::Skip] 334 | # property password : String? = "monkey" 335 | # end 336 | # 337 | # Example.new.to_json # => {"name":"Fred"} 338 | # ``` 339 | annotation Skip; end 340 | 341 | # Indicates that a property should not be serialized when it is empty. 342 | # 343 | # NOTE: Can be used on any type that defines an `#empty?` method. 344 | # ``` 345 | # class SkipWhenEmpty 346 | # include CrSerializer 347 | # 348 | # def initialize; end 349 | # 350 | # property id : Int64 = 1 351 | # 352 | # @[CRS::SkipWhenEmpty] 353 | # property value : String = "value" 354 | # 355 | # @[CRS::SkipWhenEmpty] 356 | # property values : Array(String) = %w(one two three) 357 | # end 358 | # 359 | # obj = SkipWhenEmpty.new 360 | # obj.to_json # => {"id":1,"value":"value","values":["one","two","three"]} 361 | # 362 | # obj.value = "" 363 | # obj.values = [] of String 364 | # 365 | # obj.to_json # => {"id":1} 366 | # ``` 367 | annotation SkipWhenEmpty; end 368 | 369 | # Represents the last version a property was available. 370 | # 371 | # See `CrSerializer::ExclusionStrategies::Version`. 372 | # NOTE: Value must be a `SemanticVersion` version. 373 | annotation Until; end 374 | 375 | # Can be applied to a method to make it act like a property. 376 | # ``` 377 | # class Example 378 | # include CrSerializer 379 | # 380 | # def initialize; end 381 | # 382 | # property foo : String = "foo" 383 | # 384 | # property bar : String = "bar" 385 | # 386 | # @[CRS::VirtualProperty] 387 | # @[CRS::SerializedName("testing")] 388 | # def some_method : Bool 389 | # false 390 | # end 391 | # 392 | # @[CRS::VirtualProperty] 393 | # def get_val : String 394 | # "VAL" 395 | # end 396 | # end 397 | # 398 | # Example.new.serialize JSON # => {"foo":"foo","bar":"bar","testing":false,"get_val":"VAL"} 399 | # ``` 400 | # NOTE: The return type restriction _MUST_ be defined. 401 | annotation VirtualProperty; end 402 | end 403 | -------------------------------------------------------------------------------- /src/context.cr: -------------------------------------------------------------------------------- 1 | require "semantic_version" 2 | 3 | # Stores runtime data about the current action. 4 | # 5 | # Such as what serialization groups/version to use when serializing. 6 | # 7 | # NOTE: Cannot be used for more than one action. 8 | abstract class CrSerializer::Context 9 | # The `CrSerializer::ExclusionStrategies::ExclusionStrategy` being used. 10 | getter exclusion_strategy : CrSerializer::ExclusionStrategies::ExclusionStrategy? 11 | 12 | @initalizer : Bool = false 13 | 14 | # Returns the serialization groups, if any, currently set on `self`. 15 | getter groups : Array(String)? = nil 16 | 17 | # Returns the version, if any, currently set on `self`. 18 | getter version : SemanticVersion? = nil 19 | 20 | # Adds *strategy* to `self`. 21 | # 22 | # * `exclusion_strategy` is set to *strategy* if there previously was no strategy. 23 | # * `exclusion_strategy` is set to `CrSerializer::ExclusionStrategies::Disjunct` if there was a `exclusion_strategy` already set. 24 | # * *strategy* is added to the `CrSerializer::ExclusionStrategies::Disjunct` if there are multiple strategies. 25 | def add_exclusion_strategy(strategy : CrSerializer::ExclusionStrategies::ExclusionStrategy) : self 26 | current_strategy = @exclusion_strategy 27 | case current_strategy 28 | when Nil then @exclusion_strategy = strategy 29 | when CrSerializer::ExclusionStrategies::Disjunct then current_strategy.members << strategy 30 | else 31 | @exclusion_strategy = CrSerializer::ExclusionStrategies::Disjunct.new [current_strategy, strategy] 32 | end 33 | 34 | self 35 | end 36 | 37 | # :nodoc: 38 | def init : Nil 39 | raise CrSerializer::Exceptions::LogicError.new "This context was already initialized, and cannot be re-used." if @initialized 40 | 41 | if v = @version 42 | add_exclusion_strategy CrSerializer::ExclusionStrategies::Version.new v 43 | end 44 | 45 | if g = @groups 46 | add_exclusion_strategy CrSerializer::ExclusionStrategies::Groups.new g 47 | end 48 | 49 | @initialized = true 50 | end 51 | 52 | # Sets the group(s) to compare against properties' `CRS::Groups` annotations. 53 | # 54 | # Adds a `CrSerializer::ExclusionStrategies::Groups` automatically if set. 55 | def groups=(groups : Array(String)) : self 56 | raise ArgumentError.new "Groups cannot be empty" if groups.empty? 57 | 58 | @groups = groups 59 | 60 | self 61 | end 62 | 63 | # Sets the version to compare against properties' `CRS::Since` and `CRS::Until` annotations. 64 | # 65 | # Adds a `CrSerializer::ExclusionStrategies::Version` automatically if set. 66 | def version=(version : String) : self 67 | @version = SemanticVersion.parse version 68 | 69 | self 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /src/deserialization_context.cr: -------------------------------------------------------------------------------- 1 | require "./context" 2 | 3 | # Stores runtime data about the current deserialization action. 4 | class CrSerializer::DeserializationContext < CrSerializer::Context 5 | end 6 | -------------------------------------------------------------------------------- /src/exceptions/logic_exception.cr: -------------------------------------------------------------------------------- 1 | # Raised when `CrSerializer` is used incorrectly. For example 2 | # trying to re-use a `CrSerializer::SerializationContext` object. 3 | class CrSerializer::Exceptions::LogicError < Exception 4 | end 5 | -------------------------------------------------------------------------------- /src/exceptions/parse_exception.cr: -------------------------------------------------------------------------------- 1 | # Parent class of all parse errors. 2 | # Can be used to rescue all parse errors 3 | # regardless of format. 4 | abstract class CrSerializer::Exceptions::ParseError < Exception 5 | end 6 | 7 | # Raised in the event of a JSON parse error. Such as type mistmach, missing key, etc. 8 | class CrSerializer::Exceptions::JSONParseError < CrSerializer::Exceptions::ParseError 9 | end 10 | -------------------------------------------------------------------------------- /src/exclusion_policy.cr: -------------------------------------------------------------------------------- 1 | # Defines the default exclusion strategy for all properties within a class/struct. 2 | # 3 | # See `CRS::ExclusionPolicy`. 4 | enum CrSerializer::ExclusionPolicy 5 | # Excludes all properties by default. Only properties annotated with `CRS::Expose` will be serialized/deserialized. 6 | All 7 | 8 | # Excludes no properties by default. All properties except those annotated with `CRS::Exclude` will be serialized/deserialized. 9 | None 10 | end 11 | -------------------------------------------------------------------------------- /src/exclusion_strategies/disjunct.cr: -------------------------------------------------------------------------------- 1 | require "./exclusion_strategy" 2 | 3 | # Wraps an `Array(CrSerializer::ExclusionStrategies::ExclusionStrategy)`, excluding a property if any member skips it. 4 | # 5 | # Used internally to allow multiple exclusion strategies to be used within a single instance variable for `CrSerializer::Context#add_exclusion_strategy`. 6 | struct CrSerializer::ExclusionStrategies::Disjunct < CrSerializer::ExclusionStrategies::ExclusionStrategy 7 | # The wrapped exclusion strategies. 8 | getter members : Array(ExclusionStrategy) 9 | 10 | def initialize(@members : Array(ExclusionStrategy)); end 11 | 12 | # :inherit: 13 | def skip_property?(metadata : PropertyMetadata, context : Context) : Bool 14 | @members.any?(&.skip_property?(metadata, context)) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /src/exclusion_strategies/exclusion_strategy.cr: -------------------------------------------------------------------------------- 1 | require "../CrSerializer" 2 | 3 | # Exclusion strategies are used to determine which properties within a class/struct should be serialized and deserialized. 4 | # This module includes all of the built in exclusion strategies. 5 | # 6 | # See `CrSerializer::ExclusionStrategies::ExclusionStrategy` for high level exclusion strategy documentation, as well each each specific strategy for more details. 7 | module CrSerializer::ExclusionStrategies 8 | # Base struct of all exclusion strategies. 9 | # 10 | # Custom exclusion strategies can be defined by simply inheriting from the base struct and implementing the `#skip_property?` method. 11 | abstract struct ExclusionStrategy 12 | # Returns `true` if a property should _NOT_ be serialized/deserialized. 13 | abstract def skip_property?(metadata : PropertyMetadata, context : Context) : Bool 14 | 15 | def initialize; end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /src/exclusion_strategies/groups.cr: -------------------------------------------------------------------------------- 1 | require "./exclusion_strategy" 2 | 3 | # Allows creating different views of your objects by limiting which properties get serialized, based on the group(s) each property is a part of. 4 | # 5 | # It is enabled by default when using `CrSerializer::Context#groups=`. 6 | # 7 | # ``` 8 | # class Example 9 | # include CrSerializer 10 | # 11 | # def initialize; end 12 | # 13 | # @[CRS::Groups("list", "details")] 14 | # property id : Int64 = 1 15 | # 16 | # @[CRS::Groups("list", "details")] 17 | # property title : String = "TITLE" 18 | # 19 | # @[CRS::Groups("list")] 20 | # property comment_summaries : Array(String) = ["Sentence 1.", "Sentence 2."] 21 | # 22 | # @[CRS::Groups("details")] 23 | # property comments : Array(String) = ["Sentence 1. Another sentence.", "Sentence 2. Some other stuff."] 24 | # 25 | # property created_at : Time = Time.utc(2019, 1, 1) 26 | # property updated_at : Time? 27 | # end 28 | # 29 | # example = Example.new 30 | # 31 | # example.to_json(CrSerializer::SerializationContext.new.groups = ["list"]) # => {"id":1,"title":"TITLE","comment_summaries":["Sentence 1.","Sentence 2."]} 32 | # example.to_json(CrSerializer::SerializationContext.new.groups = ["details"]) # => {"id":1,"title":"TITLE","comments":["Sentence 1. Another sentence.","Sentence 2. Some other stuff."]} 33 | # example.to_json(CrSerializer::SerializationContext.new.groups = ["list", "default"]) # => {"id":1,"title":"TITLE","comment_summaries":["Sentence 1.","Sentence 2."],"created_at":"2019-01-01T00:00:00Z"} 34 | # ``` 35 | struct CrSerializer::ExclusionStrategies::Groups < CrSerializer::ExclusionStrategies::ExclusionStrategy 36 | @groups : Array(String) 37 | 38 | def initialize(@groups : Array(String)); end 39 | 40 | def self.new(*groups : String) 41 | new groups.to_a 42 | end 43 | 44 | # :inherit: 45 | def skip_property?(metadata : PropertyMetadata, context : Context) : Bool 46 | (metadata.groups & @groups).empty? 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /src/exclusion_strategies/version.cr: -------------------------------------------------------------------------------- 1 | require "./exclusion_strategy" 2 | 3 | # Serialize properties based on a `SemanticVersion` string. 4 | # 5 | # It is enabled by default when using `CrSerializer::Context#version=`. 6 | # 7 | # ``` 8 | # class Example 9 | # include CrSerializer 10 | # 11 | # def initialize; end 12 | # 13 | # @[CRS::Until("1.0.0")] 14 | # property name : String = "Legacy Name" 15 | # 16 | # @[CRS::Since("1.1.0")] 17 | # property name2 : String = "New Name" 18 | # end 19 | # 20 | # example = Example.new 21 | # 22 | # example.to_json(CrSerializer::SerializationContext.new.version = "0.30.0") # => {"name":"Legacy Name"} 23 | # example.to_json(CrSerializer::SerializationContext.new.version = "1.2.0") # => {"name2":"New Name"} 24 | # ``` 25 | struct CrSerializer::ExclusionStrategies::Version < CrSerializer::ExclusionStrategies::ExclusionStrategy 26 | getter version : SemanticVersion 27 | 28 | def initialize(@version : SemanticVersion); end 29 | 30 | # :inherit: 31 | def skip_property?(metadata : PropertyMetadata, context : Context) : Bool 32 | # Skip if *version* is not at least *since_version*. 33 | return true if (since_version = metadata.since_version) && @version < since_version 34 | 35 | # Skip if *version* is greater than or equal to than *until_version*. 36 | return true if (until_version = metadata.until_version) && @version >= until_version 37 | 38 | false 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /src/formats/common.cr: -------------------------------------------------------------------------------- 1 | # Includes type overrides common to all formats. 2 | require "uuid" 3 | 4 | private macro define_methods 5 | def self.deserialize(format : CrSerializer::Format.class, data : String | IO, context : CrSerializer::DeserializationContext? = nil) 6 | format.deserialize \{{@type}}, data, context 7 | end 8 | 9 | def serialize(format : CrSerializer::Format.class, context : CrSerializer::SerializationContext? = nil) : String 10 | format.serialize self, context 11 | end 12 | end 13 | 14 | # :nodoc: 15 | class Array 16 | define_methods 17 | end 18 | 19 | # :nodoc: 20 | struct Bool 21 | define_methods 22 | end 23 | 24 | # :nodoc: 25 | struct Enum 26 | define_methods 27 | end 28 | 29 | # :nodoc: 30 | class Hash 31 | define_methods 32 | end 33 | 34 | # :nodoc: 35 | struct JSON::Any 36 | define_methods 37 | end 38 | 39 | # :nodoc: 40 | struct NamedTuple 41 | define_methods 42 | end 43 | 44 | # :nodoc: 45 | struct Nil 46 | define_methods 47 | end 48 | 49 | # :nodoc: 50 | struct Number 51 | define_methods 52 | end 53 | 54 | # :nodoc: 55 | struct Set 56 | define_methods 57 | end 58 | 59 | # :nodoc: 60 | class String 61 | define_methods 62 | end 63 | 64 | struct Slice(T) 65 | define_methods 66 | end 67 | 68 | # :nodoc: 69 | struct Symbol 70 | define_methods 71 | end 72 | 73 | # :nodoc: 74 | struct Time 75 | define_methods 76 | end 77 | 78 | # :nodoc: 79 | struct Tuple 80 | define_methods 81 | end 82 | 83 | # :nodoc: 84 | struct UUID 85 | define_methods 86 | end 87 | 88 | # :nodoc: 89 | struct Union 90 | define_methods 91 | end 92 | 93 | # :nodoc: 94 | struct YAML::Any 95 | define_methods 96 | end 97 | -------------------------------------------------------------------------------- /src/formats/json.cr: -------------------------------------------------------------------------------- 1 | # :nodoc: 2 | module JSON 3 | include CrSerializer::Format 4 | 5 | # Overload for Objects 6 | def self.deserialize(type : _, properties : Array(CrSerializer::Metadata), string_or_io : String | IO, context : CrSerializer::DeserializationContext) 7 | type.new properties, JSON.parse(string_or_io), context 8 | end 9 | 10 | # Overload for primitive types 11 | def self.deserialize(type : _, string_or_io : String | IO, context : CrSerializer::DeserializationContext?) 12 | type.new JSON.parse(string_or_io) 13 | rescue ex : TypeCastError 14 | if (msg = ex.message) && (deserialized_type = msg.match(/^cast from (\w+) to (\w+)/)) 15 | raise CrSerializer::Exceptions::JSONParseError.new "Expected #{type} but was #{deserialized_type[1]}" 16 | end 17 | 18 | raise ex 19 | end 20 | 21 | # Overload for Objects 22 | def self.serialize(properties : Array(CrSerializer::Metadata), context : CrSerializer::SerializationContext) : String 23 | String.build do |str| 24 | JSON.build(str) do |builder| 25 | serialize properties, context, builder 26 | end 27 | end 28 | end 29 | 30 | # Overload for Objects 31 | def self.serialize(properties : Array(CrSerializer::Metadata), context : CrSerializer::SerializationContext, builder : JSON::Builder) : Nil 32 | builder.object do 33 | properties.each do |p| 34 | builder.field(p.external_name) do 35 | p.value.serialize builder, context 36 | end 37 | end 38 | end 39 | end 40 | 41 | # Overload for primitive types 42 | def self.serialize(obj : _, context : CrSerializer::SerializationContext?) : String 43 | String.build do |str| 44 | JSON.build(str) do |builder| 45 | obj.serialize builder, context 46 | end 47 | end 48 | end 49 | end 50 | 51 | module CrSerializer 52 | macro included 53 | def self.from_json(string_or_io : String | IO, context : CrSerializer::DeserializationContext = CrSerializer::DeserializationContext.new) 54 | deserialize JSON, string_or_io, context 55 | end 56 | 57 | def self.new(properties : Array(CrSerializer::Metadata), json : JSON::Any, context : CrSerializer::DeserializationContext) 58 | instance = allocate 59 | instance.initialize properties, json, context 60 | GC.add_finalizer(instance) if instance.responds_to?(:finalize) 61 | instance 62 | end 63 | 64 | macro inherited 65 | def self.new(properties : Array(CrSerializer::Metadata), json : JSON::Any, context : CrSerializer::DeserializationContext) 66 | super 67 | end 68 | end 69 | 70 | def initialize(properties : Array(CrSerializer::Metadata), json : JSON::Any, context : CrSerializer::DeserializationContext) 71 | {% verbatim do %} 72 | {% begin %} 73 | {% for ivar, idx in @type.instance_vars %} 74 | if (prop = properties.find { |p| p.name == {{ivar.name.stringify}} }) && ((val = json[prop.external_name]?) || ((key = prop.aliases.find { |a| json[a]? }) && (val = json[key]?))) 75 | @{{ivar.id}} = {{ivar.type}}.new val 76 | else 77 | {% if !ivar.type.nilable? && !ivar.has_default_value? %} 78 | raise CrSerializer::Exceptions::JSONParseError.new "Missing json attribute: '{{ivar}}'" 79 | {% end %} 80 | end 81 | {% end %} 82 | {% end %} 83 | {% end %} 84 | end 85 | end 86 | 87 | # :nodoc: 88 | def serialize(builder : JSON::Builder, context : CrSerializer::SerializationContext?) : Nil 89 | JSON.serialize serialization_properties, context, builder 90 | end 91 | 92 | # :nodoc: 93 | def to_json(context : CrSerializer::SerializationContext = CrSerializer::SerializationContext.new) : String 94 | serialize JSON, context 95 | end 96 | end 97 | 98 | # :nodoc: 99 | class Array 100 | def self.new(json : JSON::Any) 101 | json.as_a.map { |val| T.new val } 102 | end 103 | 104 | def serialize(builder : JSON::Builder, context : CrSerializer::SerializationContext?) : Nil 105 | builder.array do 106 | each &.serialize builder, context 107 | end 108 | end 109 | end 110 | 111 | # :nodoc: 112 | struct Bool 113 | def self.new(json : JSON::Any) : Bool 114 | json.as_bool 115 | end 116 | 117 | def serialize(builder : JSON::Builder, context : CrSerializer::SerializationContext?) : Nil 118 | builder.bool self 119 | end 120 | end 121 | 122 | # :nodoc: 123 | struct Enum 124 | def self.new(json : JSON::Any) 125 | if val = json.as_i64? 126 | from_value val 127 | elsif val = json.as_s? 128 | parse val 129 | else 130 | raise CrSerializer::Exceptions::JSONParseError.new "Could not parse #{{{@type}}} from #{json}" 131 | end 132 | end 133 | 134 | def serialize(builder : JSON::Builder, context : CrSerializer::SerializationContext?) : Nil 135 | builder.number value 136 | end 137 | end 138 | 139 | # :nodoc: 140 | class Hash 141 | def self.new(json : JSON::Any) 142 | hash = new 143 | json.as_h.each do |key, value| 144 | hash[key] = V.new value 145 | end 146 | hash 147 | end 148 | 149 | def serialize(builder : JSON::Builder, context : CrSerializer::SerializationContext?) : Nil 150 | builder.object do 151 | each do |key, value| 152 | builder.field key.to_json_object_key do 153 | value.serialize builder, context 154 | end 155 | end 156 | end 157 | end 158 | end 159 | 160 | # :nodoc: 161 | struct JSON::Any 162 | def self.new(json : JSON::Any) 163 | json 164 | end 165 | 166 | def serialize(builder : JSON::Builder, context : CrSerializer::SerializationContext?) : Nil 167 | raw.serialize builder, context 168 | end 169 | end 170 | 171 | # :nodoc: 172 | struct NamedTuple 173 | def self.new(json : JSON::Any) 174 | {% begin %} 175 | {% for key, type in T %} 176 | %var{key.id} = (val = json[{{key.id.stringify}}]?) ? {{type}}.new(val) : nil 177 | {% end %} 178 | 179 | {% for key, type in T %} 180 | if %var{key.id}.nil? && !{{type.nilable?}} 181 | raise CrSerializer::Exceptions::JSONParseError.new "Missing json attribute: '{{key}}'" 182 | end 183 | {% end %} 184 | 185 | { 186 | {% for key, type in T %} 187 | {{key}}: (%var{key.id}).as({{type}}), 188 | {% end %} 189 | } 190 | {% end %} 191 | end 192 | 193 | def serialize(builder : JSON::Builder, context : CrSerializer::SerializationContext?) : Nil 194 | builder.object do 195 | {% for key in T.keys %} 196 | builder.field {{key.stringify}} do 197 | self[{{key.symbolize}}].serialize builder, context 198 | end 199 | {% end %} 200 | end 201 | end 202 | end 203 | 204 | # :nodoc: 205 | struct Nil 206 | def self.new(json : JSON::Any) : Nil 207 | json.as_nil 208 | end 209 | 210 | def serialize(builder : JSON::Builder, context : CrSerializer::SerializationContext?) : Nil 211 | builder.null 212 | end 213 | end 214 | 215 | # :nodoc: 216 | struct Number 217 | def serialize(builder : JSON::Builder, context : CrSerializer::SerializationContext?) : Nil 218 | builder.number self 219 | end 220 | end 221 | 222 | # :nodoc: 223 | struct Int8 224 | def self.new(json : JSON::Any) : Int8 225 | json.as_i.to_i8 226 | end 227 | end 228 | 229 | # :nodoc: 230 | struct Int16 231 | def self.new(json : JSON::Any) : Int16 232 | json.as_i.to_i16 233 | end 234 | end 235 | 236 | # :nodoc: 237 | struct Int32 238 | def self.new(json : JSON::Any) : Int32 239 | json.as_i 240 | end 241 | end 242 | 243 | # :nodoc: 244 | struct Int64 245 | def self.new(json : JSON::Any) : Int64 246 | json.as_i64 247 | end 248 | end 249 | 250 | # :nodoc: 251 | struct UInt8 252 | def self.new(json : JSON::Any) : UInt8 253 | json.as_i.to_u8 254 | end 255 | end 256 | 257 | # :nodoc: 258 | struct UInt16 259 | def self.new(json : JSON::Any) : UInt16 260 | json.as_i.to_u16 261 | end 262 | end 263 | 264 | # :nodoc: 265 | struct UInt32 266 | def self.new(json : JSON::Any) : UInt32 267 | json.as_i.to_u32 268 | end 269 | end 270 | 271 | # :nodoc: 272 | struct UInt64 273 | def self.new(json : JSON::Any) : UInt64 274 | json.as_i64.to_u64 275 | end 276 | end 277 | 278 | # :nodoc: 279 | struct Float32 280 | def self.new(json : JSON::Any) : Float32 281 | json.as_f.to_f32 282 | end 283 | end 284 | 285 | # :nodoc: 286 | struct Float64 287 | def self.new(json : JSON::Any) : Float64 288 | json.as_f 289 | end 290 | end 291 | 292 | # :nodoc: 293 | struct Set 294 | def self.new(json : JSON::Any) 295 | new json.as_a.map { |val| T.new val } 296 | end 297 | 298 | def serialize(builder : JSON::Builder, context : CrSerializer::SerializationContext?) : Nil 299 | builder.array do 300 | each &.serialize builder, context 301 | end 302 | end 303 | end 304 | 305 | # :nodoc: 306 | class String 307 | def self.new(json : JSON::Any) : String 308 | json.as_s 309 | end 310 | 311 | def serialize(builder : JSON::Builder, context : CrSerializer::SerializationContext?) : Nil 312 | builder.string self 313 | end 314 | end 315 | 316 | # :nodoc: 317 | struct Slice 318 | def serialize(builder : JSON::Builder, context : CrSerializer::SerializationContext?) : Nil 319 | builder.string Base64.encode(self) 320 | end 321 | end 322 | 323 | # :nodoc: 324 | struct Symbol 325 | def serialize(builder : JSON::Builder, context : CrSerializer::SerializationContext?) : Nil 326 | builder.string to_s 327 | end 328 | end 329 | 330 | # :nodoc: 331 | struct Time 332 | def self.new(json : JSON::Any) : Time 333 | Time::Format::RFC_3339.parse json.as_s 334 | end 335 | 336 | def serialize(builder : JSON::Builder, context : CrSerializer::SerializationContext?) : Nil 337 | builder.string(Time::Format::RFC_3339.format(self, fraction_digits: 0)) 338 | end 339 | end 340 | 341 | # :nodoc: 342 | struct Tuple 343 | def self.new(json : JSON::Any) 344 | arr = json.as_a 345 | {% begin %} 346 | Tuple.new( 347 | {% for type, idx in T %} 348 | {{type}}.new(arr[{{idx}}]), 349 | {% end %} 350 | ) 351 | {% end %} 352 | end 353 | 354 | def serialize(builder : JSON::Builder, context : CrSerializer::SerializationContext?) : Nil 355 | builder.array do 356 | {% for _type, idx in T %} 357 | self[{{idx}}].serialize builder, context 358 | {% end %} 359 | end 360 | end 361 | end 362 | 363 | struct Union 364 | def self.new(json : JSON::Any) 365 | {% begin %} 366 | {% non_primitives = [] of Nil %} 367 | 368 | # Try to parse the value as a primitive type first 369 | # as its faster than trying to parse a non-primitive type 370 | {% for type, index in T %} 371 | {% if type == Nil %} 372 | return nil if json.raw.is_a? Nil 373 | {% elsif type < Int %} 374 | if value = json.as_i? 375 | return {{type}}.new! value 376 | end 377 | {% elsif type < Float %} 378 | if value = json.as_f? 379 | return {{type}}.new! value 380 | end 381 | {% elsif type == Bool || type == String %} 382 | value = json.raw.as? {{type}} 383 | return value unless value.nil? 384 | {% end %} 385 | {% end %} 386 | 387 | # Parse the type directly if there is only 1 non-primitive type 388 | {% if non_primitives.size == 1 %} 389 | return {{non_primitives[0]}}.new json 390 | {% end %} 391 | {% end %} 392 | 393 | # Lastly, try to parse a non-primitive type if there are more than 1. 394 | {% for type in T %} 395 | {% if type == Nil %} 396 | return nil if json.raw.is_a? Nil 397 | {% else %} 398 | begin 399 | return {{type}}.new json 400 | rescue TypeCastError 401 | # Ignore 402 | end 403 | {% end %} 404 | {% end %} 405 | raise CrSerializer::Exceptions::JSONParseError.new "Couldn't parse #{self} from #{json}" 406 | end 407 | end 408 | 409 | # :nodoc: 410 | struct UUID 411 | def self.new(json : JSON::Any) : UUID 412 | new json.as_s 413 | end 414 | 415 | def serialize(builder : JSON::Builder, context : CrSerializer::SerializationContext?) : Nil 416 | builder.string to_s 417 | end 418 | end 419 | 420 | # :nodoc: 421 | struct YAML::Any 422 | def serialize(builder : JSON::Builder, context : CrSerializer::SerializationContext?) : Nil 423 | raw.serialize builder, context 424 | end 425 | end 426 | -------------------------------------------------------------------------------- /src/property_metadata.cr: -------------------------------------------------------------------------------- 1 | module CrSerializer 2 | # :nodoc: 3 | module Metadata; end 4 | 5 | # Represents metadata associated with a property. 6 | # 7 | # All properties are defined via annotations applied to the property, 8 | # or pulled directly from the ivar declaration. 9 | struct PropertyMetadata(IvarType, ClassType) 10 | include Metadata 11 | 12 | # The name of the property. 13 | getter name : String 14 | 15 | # The name that should be used for serialization/deserialization. 16 | getter external_name : String 17 | 18 | # The value of the property. 19 | getter value : IvarType 20 | 21 | # The type of the property. 22 | getter type : IvarType.class = IvarType 23 | 24 | # The class that the property is part of. 25 | getter class : ClassType.class = ClassType 26 | 27 | # Represents the first version this property is available. 28 | # 29 | # See `CrSerializer::ExclusionStrategies::Version`. 30 | property since_version : SemanticVersion? 31 | 32 | # Represents the last version this property was available. 33 | # 34 | # See `CrSerializer::ExclusionStrategies::Version`. 35 | property until_version : SemanticVersion? 36 | 37 | # The serialization groups this property belongs to. 38 | # 39 | # See `CrSerializer::ExclusionStrategies::Groups`. 40 | getter groups : Array(String) = ["default"] 41 | 42 | # Deserialize this property from the property's name or any name in *aliases*. 43 | # 44 | # See `CRS::Name`. 45 | getter aliases : Array(String) 46 | 47 | # If this property should not be serialized if it is empty. 48 | # 49 | # See `CRS::SkipWhenEmpty`. 50 | getter? skip_when_empty : Bool 51 | 52 | def initialize( 53 | @name : String, 54 | @external_name : String, 55 | @value : IvarType = nil, 56 | @skip_when_empty : Bool = false, 57 | @groups : Array(String) = ["default"], 58 | @aliases : Array(String) = [] of String, 59 | @since_version : SemanticVersion? = nil, 60 | @until_version : SemanticVersion? = nil, 61 | @type : IvarType.class = IvarType, 62 | @class : ClassType.class = ClassType 63 | ) 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /src/serialization_context.cr: -------------------------------------------------------------------------------- 1 | require "./context" 2 | 3 | # Stores runtime data about the current serialization action. 4 | class CrSerializer::SerializationContext < CrSerializer::Context 5 | # If `null` values should be emitted. 6 | # 7 | # ``` 8 | # class Example 9 | # include CrSerializer 10 | # 11 | # def initialize; end 12 | # 13 | # property name : String = "Jim" 14 | # property age : Int32? = nil 15 | # end 16 | # 17 | # Example.new.to_json # => {"name":"Jim"} 18 | # 19 | # context = CrSerializer::SerializationContext.new 20 | # context.emit_nil = true 21 | # 22 | # Example.new.to_json context # => {"name":"Jim","age":null} 23 | # ``` 24 | property? emit_nil : Bool = false 25 | end 26 | --------------------------------------------------------------------------------