├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── examples └── benchmark.cr ├── shard.yml ├── spec ├── serializer │ ├── base_spec.cr │ └── dsl_spec.cr └── spec_helper.cr └── src ├── serializer.cr └── serializer ├── base.cr ├── dsl.cr └── serializable.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 | schedule: 6 | - cron: "0 7 * * 1" 7 | 8 | jobs: 9 | test: 10 | strategy: 11 | fail-fast: false 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Install Crystal 17 | uses: oprypin/install-crystal@v1 18 | 19 | - name: Donwload sources 20 | uses: actions/checkout@v2 21 | 22 | - name: Check formatting 23 | run: crystal tool format --check 24 | 25 | - name: Install dependencies 26 | run: shards install --ignore-crystal-version 27 | 28 | - name: Run linter 29 | run: ./bin/ameba 30 | 31 | - name: Run specs 32 | run: crystal spec 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | *.dwarf 6 | 7 | # Libraries don't need dependency lock 8 | # Dependencies will be locked in applications that use them 9 | /shard.lock 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Roman Kalnytskyi 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 | # Serializer 2 | 3 | **Serializer** is a simple JSON serialization library for your object structure. Unlike core `JSON` module's functionality this library only covers serializing objects to JSON without parsing data back. At the same time it provides some free space for maneuvers, precise and flexible configuration WHAT, HOW and WHEN should be rendered. 4 | 5 | `Serializer::Base` only ~11% slower than `JSON::Serializable` 6 | 7 | ```text 8 | Serializer 646.00k ( 1.55µs) (± 2.52%) 2.77kB/op 1.11× slower 9 | JSON::Serializable 719.74k ( 1.39µs) (± 2.39%) 1.3kB/op fastest 10 | ``` 11 | 12 | and at the same time provides next functionality: 13 | 14 | * conditional rendering at schema definition stage 15 | * excluding specific fields at invocation stage 16 | * separation fields from relations 17 | * deep relation specification (to be rendered) at invocation stage 18 | * inheritance 19 | * optional meta data (can be specified at both definition and invocation stages). 20 | 21 | ## Installation 22 | 23 | 1. Add the dependency to your `shard.yml`: 24 | 25 | ```yaml 26 | dependencies: 27 | serializer: 28 | github: imdrasil/serializer 29 | ``` 30 | 31 | 2. Run `shards install` 32 | 33 | ## Usage 34 | 35 | Let's assume we have next resources relationship 36 | 37 | ```crystal 38 | class Parent 39 | property name, title, 40 | children : Array(Child), 41 | friends : Array(Child) 42 | 43 | def initialize(@name = "test", @title = "asd", @children = [] of Child, @friends = [] of Child) 44 | end 45 | end 46 | 47 | class Child 48 | property age : Int32, dipper : Child?, address : Address? 49 | 50 | def initialize(@age, @dipper = nil, @address = nil) 51 | end 52 | 53 | def some_sub_relation; end 54 | end 55 | 56 | class Address 57 | property street 58 | 59 | def initialize(@street = "some street") 60 | end 61 | end 62 | ``` 63 | 64 | To be able to serialize data we need to define serializers for each resource: 65 | 66 | ```crystal 67 | class AddressSerializer < Serializer::Base(Address) 68 | attributes :street 69 | end 70 | 71 | class ChildSerializer < Serializer::Base(Child) 72 | attribute :age 73 | 74 | has_one :some_sub_relation, ChildSerializer 75 | has_one :address, AddressSerializer 76 | has_one :dipper, ChildSerializer 77 | end 78 | 79 | class ModelSerializer < Serializer::Base(Model) 80 | attribute :name 81 | attribute :title, :Title, if: :test_title 82 | attribute :own_field 83 | 84 | has_many :children, ChildSerializer 85 | has_many :friends, ChildSerializer 86 | 87 | def test_title(object, options) 88 | options.nil? || !options[:test]? 89 | end 90 | 91 | def own_field 92 | 12 93 | end 94 | end 95 | ``` 96 | 97 | ### Attributes 98 | 99 | To specify what should be serialized `attributes` and `attribute` macros are used. `attributes` allows to pass a list of attribute names which maps one-to-one with JSON keys 100 | 101 | ```crystal 102 | class PostSerializer 103 | attributes :title, body 104 | end 105 | ``` 106 | 107 | Above serializer will produce next output `{"title": "Some title", "body": "Post body"}`. You can precisely configure every field using `attribute` macro. It allows to specify `key` name to be used in JSON and `if` predicate method name to be used to check whether field should be serialized. 108 | 109 | ```crystal 110 | class ModelSerializer < Serializer::Base(Model) 111 | attribute :title, :Title, if: :test_title 112 | 113 | def test_title(object, options) 114 | options.nil? || !options[:test]? 115 | end 116 | end 117 | ``` 118 | 119 | Above serializer will produce next output `{"Title": "Some title"}` if serializer has got options without `test` set to `true`. 120 | 121 | If serializer has a method with the same name as specified field - it is used. 122 | 123 | ```crystal 124 | class ModelSerializer < Serializer::Base(Model) 125 | attribute :name 126 | 127 | def name 128 | "StaticName" 129 | end 130 | end 131 | ``` 132 | 133 | ### Relations 134 | 135 | If resource has underlying resources to serialize they can be specified with `has_one`, `belongs_to` and `has_many` macro methods that describes relation type between them (one-to-one, one-to-any and one-to-many). 136 | 137 | ```crystal 138 | class ModelSerializer < Serializer::Base(Model) 139 | has_many :friends, ChildSerializer 140 | end 141 | ``` 142 | 143 | They also accepts `key` option. There is no `if` support because associations by default isn't rendered. 144 | 145 | ### Meta 146 | 147 | Resource meta data can be defined at it's level - overriding `.meta` method. 148 | 149 | ```crystal 150 | class ModelSerializer < Serializer::Base(Model) 151 | def self.meta(options) 152 | { 153 | :page => options[:page] 154 | } 155 | end 156 | end 157 | ``` 158 | 159 | Method return value should be `Hash(Symbol, JSON::Any::Type | Int32)`. Also any additional meta attributes may be defined at serialization moment (calling `#serialize` method). 160 | 161 | ### Inheritance 162 | 163 | If you have complicated domain object relation structure - you can easily present serialization logic using inheritance: 164 | 165 | ```crystal 166 | class ModelSerializer < Serializer::Base(Model) 167 | attribute :name 168 | end 169 | 170 | class InheritedSerializer < ModelSerializer 171 | attribute :inherited_field 172 | 173 | def inherited_field 174 | 1.23 175 | end 176 | end 177 | ``` 178 | 179 | ### Rendering 180 | 181 | To render resource create an instance of required serializer and call `#serialize`: 182 | 183 | ```crystal 184 | ModelSerializer.new(model).serialize 185 | ``` 186 | 187 | It accepts several optional arguments: 188 | 189 | * `except` - array of fields that should not be serialized; 190 | * `includes` - relations that should be included into serialized string; 191 | * `opts` - options that will be passed to *if* predicate methods and `.meta`; 192 | * `meta` - meta attributes to be added under `"meta"` key at root level; it is merged into default meta attributes returned by `.meta`. 193 | 194 | ```crystal 195 | ModelSerializer.new(model).serialize( 196 | except: [:own_field], 197 | includes: { 198 | :children => [:some_sub_relation], 199 | :friends => { :address => nil, :dipper => [:some_sub_relation] } 200 | }, 201 | meta: { :page => 0 } 202 | ) 203 | ``` 204 | 205 | `includes` should be array or hash (any levels deep) which elements presents relation names to be serialized. `nil` value may be used in hashes as a value to define that nothing additional should be serialized for a relation named by corresponding key. 206 | 207 | Example above results in: 208 | 209 | ```json 210 | { 211 | "data":{ 212 | "name":"test", 213 | "Title":"asd", 214 | "children":[], 215 | "friends":[ 216 | { 217 | "age":60, 218 | "address":{ 219 | "street":"some street" 220 | }, 221 | "dipper":{ 222 | "age":20, 223 | "some_sub_relation":null 224 | } 225 | } 226 | ] 227 | }, 228 | "meta":{ 229 | "page":0 230 | } 231 | } 232 | ``` 233 | 234 | > This is pretty JSON version - actual result contains no spaces and newlines. 235 | 236 | #### Root key 237 | 238 | Serialized JSON root level includes `data` key (and optional `meta` key). It can be renamed to anything by defining `.root_key` 239 | 240 | ```crystal 241 | class ModelSerializer < Serializer::Base(Model) 242 | def self.root_key 243 | "model" 244 | end 245 | 246 | attribute :name 247 | end 248 | ``` 249 | 250 | For API details see [documentation](https://imdrasil.github.io/serializer/latest/serializer). 251 | 252 | ## Contributing 253 | 254 | 1. Fork it () 255 | 2. Create your feature branch (`git checkout -b my-new-feature`) 256 | 3. Commit your changes (`git commit -am 'Add some feature'`) 257 | 4. Push to the branch (`git push origin my-new-feature`) 258 | 5. Create a new Pull Request 259 | 260 | ## Contributors 261 | 262 | - [Roman Kalnytskyi](https://github.com/imdrasil) - creator and maintainer 263 | -------------------------------------------------------------------------------- /examples/benchmark.cr: -------------------------------------------------------------------------------- 1 | # crystal examples/benchmark.cr --release 2 | 3 | require "benchmark" 4 | require "../src/serializer" 5 | 6 | class Model 7 | property name, title, 8 | children : Array(Child), 9 | friends : Array(Child), 10 | parents : Array(Child) 11 | 12 | def initialize(@name = "test", @title = "asd", @children = [] of Child, @friends = [] of Child, @parents = friends) 13 | end 14 | end 15 | 16 | class Child 17 | property age : Int32, dipper : Child?, address : Address? 18 | 19 | def initialize(@age, @dipper = nil, @address = nil) 20 | end 21 | 22 | def sub; end 23 | end 24 | 25 | class Address 26 | property street 27 | 28 | def initialize(@street = "some street") 29 | end 30 | end 31 | 32 | class AddressSerializer < Serializer::Base(Address) 33 | attributes :street 34 | end 35 | 36 | class ChildSerializer < Serializer::Base(Child) 37 | attribute :age 38 | 39 | has_one :sub, ChildSerializer 40 | has_one :address, AddressSerializer 41 | has_one :dipper, ChildSerializer 42 | end 43 | 44 | class ModelSerializer < Serializer::Base(Model) 45 | attribute :name 46 | attribute :title, :Title, if: :test_title 47 | attribute :own_field 48 | 49 | has_many :children, ChildSerializer 50 | has_many :parents, ChildSerializer, :Parents 51 | has_many :friends, ChildSerializer 52 | 53 | def test_title(object, options) 54 | options.nil? || !options[:test]? 55 | end 56 | 57 | def own_field 58 | 12 59 | end 60 | end 61 | 62 | class CoreAddressSerializer 63 | include JSON::Serializable 64 | 65 | getter street : String 66 | 67 | def initialize(address) 68 | @street = address.street 69 | end 70 | end 71 | 72 | class CoreChildSerializer 73 | include JSON::Serializable 74 | 75 | getter age : Int32, sub : CoreChildSerializer?, address : CoreAddressSerializer?, dipper : CoreChildSerializer? 76 | 77 | def initialize(child) 78 | @age = child.age 79 | @sub = child.sub 80 | @address = CoreAddressSerializer.new(child.address.not_nil!) if child.address 81 | @dipper = CoreChildSerializer.new(child.dipper.not_nil!) if child.dipper 82 | end 83 | end 84 | 85 | class CoreModelSerializer 86 | include JSON::Serializable 87 | 88 | getter name : String, title : String, own_field : Int32, children : Array(CoreChildSerializer), parents : Array(CoreChildSerializer), friends : Array(CoreChildSerializer) 89 | 90 | def initialize(model) 91 | @name = model.name 92 | @title = model.title 93 | @own_field = own_field 94 | @children = model.children.map { |e| CoreChildSerializer.new(e) } 95 | @parents = model.parents.map { |e| CoreChildSerializer.new(e) } 96 | @friends = model.friends.map { |e| CoreChildSerializer.new(e) } 97 | end 98 | 99 | def own_field 100 | 12 101 | end 102 | end 103 | 104 | class CoreRootSerializer 105 | include JSON::Serializable 106 | 107 | def initialize(data) 108 | @data = CoreModelSerializer.new(data) 109 | end 110 | end 111 | 112 | model = Model.new(friends: [Child.new(60, Child.new(20, address: Address.new))], parents: [] of Child) 113 | nesting = {:children => [:sub], :friends => {:address => nil, :dipper => [:sub]}} 114 | 115 | Benchmark.ips do |x| 116 | x.report("Serializer") { ModelSerializer.new(model).serialize(includes: nesting) } 117 | x.report("JSON::Serializable") { CoreRootSerializer.new(model).to_json } 118 | end 119 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: serializer 2 | version: 0.2.0 3 | 4 | authors: 5 | - Roman Kalnytskyi 6 | 7 | crystal: ">= 1.0.0, < 2.0.0" 8 | 9 | development_dependencies: 10 | ameba: 11 | github: crystal-ameba/ameba 12 | version: "= 0.14.3" 13 | 14 | license: MIT 15 | -------------------------------------------------------------------------------- /spec/serializer/base_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper.cr" 2 | 3 | class AddressWithMetaSerializer < Serializer::Base(Address) 4 | attributes :street 5 | 6 | def self.meta(*opts) 7 | {:page => 0} 8 | end 9 | end 10 | 11 | class ModelWithoutRootSerializer < ModelSerializer 12 | def self.root_key 13 | nil 14 | end 15 | end 16 | 17 | describe Serializer::Base do 18 | single_serializer = ModelSerializer.new(Model.new) 19 | 20 | describe ".new" do 21 | context "with object" do 22 | it { ModelSerializer.new(Model.new) } 23 | end 24 | 25 | context "with collection" do 26 | it { ModelSerializer.new([Model.new]) } 27 | end 28 | 29 | context "with nil" do 30 | it { ModelSerializer.new(nil) } 31 | end 32 | end 33 | 34 | describe "#serialize" do 35 | describe "single object" do 36 | it { single_serializer.serialize.should eq("{\"data\":{\"name\":\"test\",\"Title\":\"asd\",\"own_field\":12}}") } 37 | end 38 | 39 | describe "collection" do 40 | it { ModelSerializer.new([Model.new]).serialize.should eq("{\"data\":[{\"name\":\"test\",\"Title\":\"asd\",\"own_field\":12}]}") } 41 | end 42 | 43 | describe "nil" do 44 | it { ModelSerializer.new(nil).serialize.should eq(%({"data":null})) } 45 | end 46 | 47 | describe "inheritance" do 48 | it do 49 | InheritedSerializer.new(Model.new).serialize(except: %i(name), includes: %i(children)) 50 | .should eq("{\"data\":{\"Title\":\"asd\",\"own_field\":12,\"inherited_field\":1.23,\"children\":[]}}") 51 | end 52 | end 53 | 54 | context "with except" do 55 | it { single_serializer.serialize(except: %i(name)).should_not contain(%("name":)) } 56 | end 57 | 58 | context "with includes" do 59 | it { single_serializer.serialize(includes: %i(children)).should eq("{\"data\":{\"name\":\"test\",\"Title\":\"asd\",\"own_field\":12,\"children\":[]}}") } 60 | it do 61 | single_serializer.serialize(includes: {:children => [:sub], :friends => {:address => nil, :dipper => [:sub]}}) 62 | .should eq("{\"data\":{\"name\":\"test\",\"Title\":\"asd\",\"own_field\":12,\"children\":[],\"friends\":[{\"age\":60,\"address\":{\"street\":\"some street\"},\"dipper\":{\"age\":100,\"sub\":null}}]}}") 63 | end 64 | end 65 | 66 | context "with options" do 67 | it { single_serializer.serialize(opts: {:test => true}).should_not contain(%("Title")) } 68 | end 69 | 70 | context "with meta" do 71 | it do 72 | single_serializer.serialize(meta: {:page => 0}) 73 | .should eq("{\"data\":{\"name\":\"test\",\"Title\":\"asd\",\"own_field\":12},\"meta\":{\"page\":0}}") 74 | end 75 | 76 | context "with default meta" do 77 | it { AddressWithMetaSerializer.new(Address.new).serialize.should eq("{\"data\":{\"street\":\"some street\"},\"meta\":{\"page\":0}}") } 78 | it { AddressWithMetaSerializer.new(Address.new).serialize(meta: {:total => 0}).should eq("{\"data\":{\"street\":\"some street\"},\"meta\":{\"page\":0,\"total\":0}}") } 79 | it { AddressWithMetaSerializer.new(Address.new).serialize(meta: {:page => 3}).should eq("{\"data\":{\"street\":\"some street\"},\"meta\":{\"page\":3}}") } 80 | end 81 | end 82 | 83 | context "without root" do 84 | it do 85 | ModelWithoutRootSerializer.new(Model.new).serialize.should eq("{\"name\":\"test\",\"Title\":\"asd\",\"own_field\":12}") 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /spec/serializer/dsl_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe Serializer::DSL do 4 | describe ".attribute" do 5 | describe "key" do 6 | it "uses name by default" do 7 | ModelSerializer.new(Model.new).serialize.should contain(%("name":)) 8 | end 9 | 10 | it "uses specified name if given" do 11 | ModelSerializer.new(Model.new).serialize.should contain(%("Title":)) 12 | end 13 | end 14 | 15 | describe "if" do 16 | it do 17 | serializer = ModelSerializer.new(Model.new) 18 | serializer.serialize.should contain(%(Title)) 19 | serializer.serialize(opts: {:test => true}).should_not contain(%(Title)) 20 | end 21 | end 22 | end 23 | 24 | describe "relation" do 25 | describe "key" do 26 | it "uses name by default" do 27 | ModelSerializer.new(Model.new).serialize(includes: %i(children)).should contain(%("children":)) 28 | end 29 | 30 | it "uses specified name if given" do 31 | ModelSerializer.new(Model.new).serialize(includes: %i(parents)).should contain(%("Parents":)) 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/serializer" 3 | 4 | # =============== 5 | # ==== Models 6 | # =============== 7 | 8 | class Model 9 | property name, title, 10 | children : Array(Child) = [] of Child 11 | 12 | def initialize(@name = "test", @title = "asd") 13 | end 14 | 15 | def friends 16 | [Child.new(60)] 17 | end 18 | 19 | def parents 20 | friends 21 | end 22 | end 23 | 24 | class Child 25 | property age 26 | 27 | def initialize(@age = 25) 28 | end 29 | 30 | def sub; end 31 | 32 | def address 33 | Address.new 34 | end 35 | 36 | def dipper 37 | Child.new(100) 38 | end 39 | end 40 | 41 | class Address 42 | property street 43 | 44 | def initialize(@street = "some street") 45 | end 46 | end 47 | 48 | # =============== 49 | # === Serializers 50 | # =============== 51 | 52 | class AddressSerializer < Serializer::Base(Address) 53 | attributes :street 54 | end 55 | 56 | class ChildSerializer < Serializer::Base(Child) 57 | attribute :age 58 | 59 | has_one :sub, ChildSerializer 60 | has_one :address, AddressSerializer 61 | has_one :dipper, ChildSerializer 62 | end 63 | 64 | class ModelSerializer < Serializer::Base(Model) 65 | attribute :name 66 | attribute :title, :Title, if: :test_title 67 | attribute :own_field 68 | 69 | has_many :children, ChildSerializer 70 | has_many :parents, ChildSerializer, :Parents 71 | has_many :friends, ChildSerializer 72 | 73 | def test_title(object, options) 74 | options.nil? || !options[:test]? 75 | end 76 | 77 | def own_field 78 | 12 79 | end 80 | end 81 | 82 | class InheritedSerializer < ModelSerializer 83 | attribute :inherited_field 84 | 85 | def inherited_field 86 | 1.23 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /src/serializer.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "./serializer/base" 3 | 4 | module Serializer 5 | VERSION = "0.2.0" 6 | end 7 | -------------------------------------------------------------------------------- /src/serializer/base.cr: -------------------------------------------------------------------------------- 1 | require "./dsl" 2 | require "./serializable" 3 | 4 | module Serializer 5 | # Base serialization superclass. 6 | # 7 | # ``` 8 | # class AddressSerializer < Serializer::Base(Address) 9 | # attributes :street 10 | # end 11 | # 12 | # class ChildSerializer < Serializer::Base(Child) 13 | # attribute :age 14 | # 15 | # has_one :address, AddressSerializer 16 | # has_one :dipper, ChildSerializer 17 | # end 18 | # 19 | # class ModelSerializer < Serializer::Base(Model) 20 | # attribute :name 21 | # attribute :own_field 22 | # 23 | # has_many :children, ChildSerializer 24 | # 25 | # def own_field 26 | # 12 27 | # end 28 | # end 29 | # 30 | # ModelSerializer.new(object).serialize( 31 | # except: [:own_field], 32 | # includes: { 33 | # :children => {:address => nil, :dipper => [:address]}, 34 | # }, 35 | # meta: {:page => 0} 36 | # ) 37 | # ``` 38 | # 39 | # Example above produces next output (this one is made to be readable - 40 | # real one has no newlines and indentations): 41 | # 42 | # ```json 43 | # { 44 | # "data":{ 45 | # "name":"test", 46 | # "children":[ 47 | # { 48 | # "age":60, 49 | # "address":null, 50 | # "dipper":{ 51 | # "age":20, 52 | # "address":{ 53 | # "street":"some street" 54 | # } 55 | # } 56 | # } 57 | # ] 58 | # }, 59 | # "meta":{ 60 | # "page":0 61 | # } 62 | # } 63 | # ``` 64 | # 65 | # For a details about DSL specification or serialization API see `DSL` and `Serializable`. 66 | # 67 | # ## Inheritance 68 | # 69 | # You can DRY your serializers by inheritance - just add required attributes and/or associations in 70 | # the subclasses. 71 | # 72 | # ``` 73 | # class UserSerializer < Serializer::Base(User) 74 | # attributes :name, :age 75 | # end 76 | # 77 | # class FullUserSerializer < UserSerializer 78 | # attributes :email, :created_at 79 | # 80 | # has_many :identities, IdentitySerializer 81 | # end 82 | # ``` 83 | abstract class Base(T) < Serializable 84 | include DSL 85 | 86 | # :nodoc: 87 | macro define_serialization 88 | {% if !@type.has_constant?("ATTRIBUTES") %} 89 | # :nodoc: 90 | ATTRIBUTES = {} of Nil => Nil 91 | {% end %} 92 | 93 | {% if !@type.has_constant?("RELATIONS") %} 94 | # :nodoc: 95 | RELATIONS = {} of Nil => Nil 96 | {% end %} 97 | 98 | macro finished 99 | {% verbatim do %} 100 | {% superclass = @type.superclass %} 101 | 102 | {% if ATTRIBUTES.size > 0 %} 103 | # :nodoc: 104 | def serialize_attributes(object, io, except, opts) 105 | fields_count = 106 | {{ superclass.methods.any?(&.name.==(:serialize_attributes.id)) ? :super.id : 0 }} 107 | {% for name, props in ATTRIBUTES %} 108 | {% target = @type.has_method?(name) ? :self : :object %} 109 | if !except.includes?(:{{name.id}}) {% if props[:if] %} && {{props[:if].id}}(object, opts) {% end %} 110 | io << "," if fields_count > 0 111 | fields_count += 1 112 | io << "\"{{props[:key].id}}\":" << {{target.id}}.{{name.id}}.to_json 113 | end 114 | {% end %} 115 | fields_count 116 | end 117 | {% end %} 118 | 119 | {% if RELATIONS.size > 0 %} 120 | # :nodoc: 121 | def serialize_relations(object, fields_count, io, includes, opts) 122 | {% if superclass.methods.any?(&.name.==(:serialize_relations.id)) %} super {% end %} 123 | {% for name, props in RELATIONS %} 124 | {% if props[:type] == :has_many || props[:type] == :has_one || props[:type] == :belongs_to %} 125 | if has_relation?({{name}}, includes) 126 | io << "," if fields_count > 0 127 | fields_count += 1 128 | io << "\"{{props[:key].id}}\":" 129 | {{props[:serializer]}}.new(object.{{name.id}})._serialize(object.{{name.id}}, io, [] of Symbol, nested_includes({{name}}, includes), opts) 130 | end 131 | {% end %} 132 | {% end %} 133 | fields_count 134 | end 135 | {% end %} 136 | {% end %} 137 | end 138 | 139 | macro inherited 140 | define_serialization 141 | end 142 | end 143 | 144 | macro inherited 145 | define_serialization 146 | end 147 | 148 | # Entity to be serialized. 149 | protected getter target 150 | 151 | def initialize(@target : T | Array(T)?) 152 | end 153 | 154 | def serialize_attributes(object, io, except, opts) 155 | 0 156 | end 157 | 158 | def serialize_relations(object, fields_count, io, includes, opts) 159 | fields_count 160 | end 161 | 162 | # :nodoc: 163 | def _serialize(object : T, io : IO, except : Array, includes : Array | Hash, opts : Hash?) 164 | io << "{" 165 | fields_count = serialize_attributes(object, io, except, opts) 166 | serialize_relations(object, fields_count, io, includes, opts) 167 | io << "}" 168 | end 169 | 170 | # :nodoc: 171 | def _serialize(collection : Array(T), io : IO, except : Array, includes : Array | Hash, opts : Hash?) 172 | io << "[" 173 | collection.each_with_index do |object, index| 174 | io << "," if index != 0 175 | _serialize(object, io, except, includes, opts) 176 | end 177 | io << "]" 178 | end 179 | 180 | # :nodoc: 181 | def render_root(io : IO, except : Array, includes : Array | Hash, opts : Hash?, meta) 182 | io << "{\"" << self.class.root_key << "\":" 183 | _serialize(@target, io, except, includes, opts) 184 | default_meta = self.class.meta(opts) 185 | unless meta.nil? 186 | meta.each do |key, value| 187 | default_meta[key] = value 188 | end 189 | end 190 | io << %(,"meta":) << default_meta.to_json unless default_meta.empty? 191 | io << "}" 192 | end 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /src/serializer/dsl.cr: -------------------------------------------------------------------------------- 1 | module Serializer 2 | # Contains DSL required to define required fields and relations for serialization. 3 | # 4 | # ``` 5 | # class UserSerializer < Serializer::Base(User) 6 | # attribute :name 7 | # attribute :first_name, "first-name" 8 | # attribute :email, if: :secure? 9 | # 10 | # has_many :posts, PostSerializer 11 | # 12 | # def secure?(record, options) 13 | # options && options[:secure]? 14 | # end 15 | # end 16 | # ``` 17 | module DSL 18 | # Defines list of attributes to be serialized from target. 19 | # 20 | # ``` 21 | # class UserSerializer < Serializer::Base(User) 22 | # attributes :name, :first_name, :email 23 | # end 24 | # ``` 25 | macro attributes(*names) 26 | {% 27 | names.reduce(ATTRIBUTES) do |hash, name| 28 | hash[name] = {key: name, if: nil} 29 | hash 30 | end 31 | %} 32 | end 33 | 34 | # Defines *name* attribute to be serialized. 35 | # 36 | # *name* values will be used as a method name that is called on target object. Also it can be 37 | # a serializer's own method name. In such case it is called instead. 38 | # 39 | # Options: 40 | # 41 | # * *key* - json key; equals to *name* by default; 42 | # * *if* - name of a method to be used to check whether attribute *name* should be serialized. 43 | # 44 | # Method given to the *if* should have following signature: 45 | # 46 | # `abstract def method(object : T, options : Hash(Symbol, Serializer::MetaAny)?)` 47 | # 48 | # Returned type will be used in `if` clause. 49 | # 50 | # ``` 51 | # class UserSerializer < Serializer::Base(User) 52 | # attribute :name 53 | # attribute :first_name, "first-name" 54 | # attribute :email, if: :secure? 55 | # 56 | # def secure?(record, options) 57 | # options && options[:secure]? 58 | # end 59 | # end 60 | # ``` 61 | macro attribute(name, key = nil, if if_proc = nil) 62 | {% ATTRIBUTES[name] = {key: key || name, if: if_proc} %} 63 | end 64 | 65 | # Defines `one-to-many` *name* association that is serialized by *serializer*. 66 | # 67 | # Options: 68 | # 69 | # * *key* - json key; equals to *name* by default; 70 | # * *serializer* - class to be used for association serialization. 71 | # 72 | # ``` 73 | # class UserSerializer < Serializer::Base(User) 74 | # has_many :posts, PostSerializer 75 | # has_many :post_comments, CommentSerializer, "postComments" 76 | # end 77 | # ``` 78 | # 79 | # By default all associations are not serialized. To make an association being serialized 80 | # it should be explicitly specified in *includes* argument of `Base#serialize` method. 81 | macro has_many(name, serializer, key = nil) 82 | {% RELATIONS[name] = {serializer: serializer, key: key || name, type: :has_many} %} 83 | end 84 | 85 | # Defines `one-to-one` *name* association that is serialized by *serializer*. 86 | # 87 | # For more details see `.has_many`. 88 | macro has_one(name, serializer, key = nil) 89 | {% RELATIONS[name] = {serializer: serializer, key: key || name, type: :has_one} %} 90 | end 91 | 92 | # Defines `one-to-any` *name* association that is serialized by *serializer*. 93 | # 94 | # For more details see `.has_many`. 95 | macro belongs_to(name, serializer, key = nil) 96 | {% RELATIONS[name] = {serializer: serializer, key: key || name, type: :belongs_to} %} 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /src/serializer/serializable.cr: -------------------------------------------------------------------------------- 1 | module Serializer 2 | # Allowed types for *meta* hash values. 3 | alias MetaAny = JSON::Any::Type | Int32 4 | 5 | # Base abstract superclass for serialization. 6 | abstract class Serializable 7 | # Abstract serializer static methods. 8 | module AbstractClassMethods 9 | # Returns json root key. 10 | # 11 | # Default data root key is `"data"`. This behavior can be override by overriding this method. 12 | # It can be omited by setting nil, but any meta-options and `meta` keys will be ignored. 13 | # ``` 14 | # class UserSerializer < Serializer::Base(User) 15 | # def self.root_key 16 | # "user" 17 | # end 18 | # end 19 | # ``` 20 | abstract def root_key 21 | 22 | # Returns default meta options. 23 | # 24 | # If this is empty and no additional meta-options are given - `meta` key is avoided. To define own default meta options 25 | # just override this in your serializer: 26 | # 27 | # ``` 28 | # class UserSerializer < Serializer::Base(User) 29 | # def self.meta(opts) 30 | # { 31 | # :status => "ok", 32 | # } of Symbol => Serializer::MetaAny 33 | # end 34 | # end 35 | # ``` 36 | abstract def meta(opts) : Hash(Symbol, MetaAny) 37 | 38 | def root_key : String | Nil 39 | "data" 40 | end 41 | 42 | def meta(_opts) 43 | {} of Symbol => MetaAny 44 | end 45 | end 46 | 47 | extend AbstractClassMethods 48 | 49 | # Serializes *target*'s attributes to *io*. 50 | abstract def serialize_attributes(target, io, except, opts) 51 | 52 | # Serializes *target*'s relations to *io*. 53 | abstract def serialize_relations(target, fields_count, io, includes, opts) 54 | 55 | # Generates a JSON formatted string. 56 | # 57 | # Arguments: 58 | # 59 | # * *except* - array of fields should be excluded from serialization; 60 | # * *includes* - definition of relation that should be included into serialized string; 61 | # * *opts* - options that will be passed to methods defined for *if* attribute options and `.meta`; 62 | # * *meta* - meta attributes to be added under `"meta"` key at root level; it is merge into default 63 | # meta attributes returned by `.meta`. 64 | # 65 | # ``` 66 | # ModelSerializer.new(object).serialize( 67 | # except: [:own_field], 68 | # includes: { 69 | # :children => {:address => nil, :dipper => [:address]}, 70 | # }, 71 | # meta: {:page => 0} 72 | # ) 73 | # ``` 74 | # 75 | # ## Includes 76 | # 77 | # *includes* option accepts `Array` or `Hash` values. To define just a list of association of target object - just pass an array: 78 | # 79 | # ``` 80 | # ModelSerializer.new(object).serialize(includes: [:children]) 81 | # ``` 82 | # 83 | # You can also specify deeper and more sophisticated schema by passing `Hash`. In this case hash values should be of 84 | # `Array(Symbol) | Hash | Nil` type. `nil` is used to mark association which name is used for key as a leaf in schema 85 | # tree. 86 | def serialize(except : Array(Symbol) = %i(), includes : Array(Symbol) | Hash = %i(), opts : Hash? = nil, meta : Hash(Symbol, MetaAny)? = nil) 87 | String.build do |io| 88 | serialize(io, except, includes, opts, meta) 89 | end 90 | end 91 | 92 | # :nodoc: 93 | def serialize(io : IO, except = %i(), includes = %i(), opts : Hash? = nil, meta : Hash? = nil) 94 | if self.class.root_key.nil? 95 | _serialize(@target, io, except, includes, opts) 96 | else 97 | render_root(io, except, includes, opts, meta) 98 | end 99 | end 100 | 101 | # :nodoc: 102 | def _serialize(object : Nil, io : IO, except : Array, includes : Array | Hash, opts : Hash?) 103 | io << "null" 104 | end 105 | 106 | # Returns whether *includes* has a mention for relation *name*. 107 | protected def has_relation?(name, includes : Array) 108 | includes.includes?(name) 109 | end 110 | 111 | protected def has_relation?(name, includes : Hash) 112 | includes.has_key?(name) 113 | end 114 | 115 | # Returns nested inclusions for relation *name*. 116 | protected def nested_includes(name, includes : Array) 117 | %i() 118 | end 119 | 120 | protected def nested_includes(name, includes : Hash) 121 | includes[name] || %i() 122 | end 123 | end 124 | end 125 | --------------------------------------------------------------------------------