├── .gitignore ├── .rspec ├── .travis.yml ├── .yardopts ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── elabs-logo.png ├── lib ├── serial.rb └── serial │ ├── array_builder.rb │ ├── builder.rb │ ├── hash_builder.rb │ ├── rails_helpers.rb │ ├── serializer.rb │ └── version.rb ├── serial.gemspec └── spec ├── serial ├── dsl_spec.rb ├── rails_helpers_spec.rb └── serializer_spec.rb ├── serial_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | --format documentation 3 | --color 4 | --tty 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.1 4 | - 2.2 5 | - ruby-head 6 | - jruby 7 | - rbx-2 8 | before_install: gem install bundler -v 1.10.6 9 | matrix: 10 | allow_failures: 11 | - rvm: ruby-head 12 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --asset elabs-logo.png 2 | --files CODE_OF_CONDUCT.md,CHANGELOG.md 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 1.0.0 4 | 5 | - Add HashBuilder#merge. [cb11a3c5] 6 | - Raise an error by default if a key already exists in HashBuilder. [4b46db57] 7 | 8 | ## 0.2.0 9 | 10 | - Raise an error if serializer is not given a block. 11 | - Add RailsHelpers. 12 | - Fix ArrayBuilder#collection taking a key parameter. 13 | 14 | ## 0.1.0 15 | 16 | - Initial release. 17 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all 4 | people who contribute through reporting issues, posting feature requests, 5 | updating documentation, submitting pull requests or patches, and other 6 | activities. 7 | 8 | We are committed to making participation in this project a harassment-free 9 | experience for everyone, regardless of level of experience, gender, gender 10 | identity and expression, sexual orientation, disability, personal appearance, 11 | body size, race, age, or religion. 12 | 13 | Examples of unacceptable behavior by participants include the use of sexual 14 | language or imagery, derogatory comments or personal attacks, trolling, public 15 | or private harassment, insults, or other unprofessional conduct. 16 | 17 | Project maintainers have the right and responsibility to remove, edit, or 18 | reject comments, commits, code, wiki edits, issues, and other contributions 19 | that are not aligned to this Code of Conduct. Project maintainers who do not 20 | follow the Code of Conduct may be removed from the project team. 21 | 22 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 23 | reported by opening an issue or contacting one or more of the project 24 | maintainers. 25 | 26 | This Code of Conduct is adapted from the [Contributor 27 | Covenant](http:contributor-covenant.org), version 1.0.0, available at 28 | [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 29 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | group :development do 6 | platform :ruby do 7 | gem "sqlite3" 8 | end 9 | 10 | platform :jruby do 11 | gem "jdbc-sqlite3" 12 | gem "activerecord-jdbcsqlite3-adapter" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Serial 2 | 3 | [![Build Status](https://travis-ci.org/elabs/serial.svg?branch=master)](http://travis-ci.org/elabs/serial) 4 | [![Dependency Status](https://gemnasium.com/elabs/serial.svg)](https://gemnasium.com/elabs/serial) 5 | [![Code Climate](https://codeclimate.com/github/elabs/serial/badges/gpa.svg)](https://codeclimate.com/github/elabs/serial) 6 | [![Gem Version](https://badge.fury.io/rb/serial.svg)](http://badge.fury.io/rb/serial) 7 | [![Inline docs](http://inch-ci.org/github/elabs/serial.svg?branch=master&style=shields)](http://inch-ci.org/github/elabs/serial) 8 | 9 | *Psst, full documentation can be found at [rubydoc.info/gems/serial](http://www.rubydoc.info/gems/serial)* 10 | 11 | Serial is a light-weight and simple serialization library. Its primary purpose is to generate primitive 12 | datastructures from object graphs, in other words to help you serialize your data. 13 | 14 | Serial is sponsored by [Elabs][]. 15 | 16 | [![elabs logo][]][Elabs] 17 | 18 | [Elabs]: http://www.elabs.se/ 19 | [elabs logo]: ./elabs-logo.png?raw=true 20 | 21 | ## Installation 22 | 23 | Add this line to your application's Gemfile: 24 | 25 | ```ruby 26 | gem "serial" 27 | ``` 28 | 29 | And then execute: 30 | 31 | $ bundle 32 | 33 | ## The DSL 34 | 35 | *Full reference: [Serial::HashBuilder](http://www.rubydoc.info/gems/serial/Serial/HashBuilder), [Serial::ArrayBuilder](http://www.rubydoc.info/gems/serial/Serial/ArrayBuilder).* 36 | 37 | - All keys are turned into strings. 38 | - There is no automatic camel-casing. You name your keys the way you want them. 39 | - Using the same key twice will raise an error by default. 40 | - To override the value for an existing key, use the respective !-method DSL, i.e. `#attribute!`, `#collection!`, `#map!`, or `#merge!`. 41 | 42 | ### Simple attributes 43 | 44 | `#attribute` creates a simple attribute with a value. 45 | 46 | ``` ruby 47 | ProjectSerializer = Serial::Serializer.new do |h, project| 48 | h.attribute(:id, project.id) 49 | h.attribute(:displayName, project.display_name) 50 | end # => { "id" => …, "displayName" => … } 51 | ``` 52 | 53 | ### Nested attributes 54 | 55 | `#attribute` supports nesting by giving it a block. 56 | 57 | ``` ruby 58 | ProjectSerializer = Serial::Serializer.new do |h, project| 59 | h.attribute(:owner, project.owner) do |h, owner| 60 | h.attribute(:name, owner.name) 61 | end 62 | end # => { "owner" => { "name" => … } } 63 | ``` 64 | 65 | ### Collections 66 | 67 | `#map` is a convenient method for serializing lists of items. 68 | 69 | ``` ruby 70 | ProjectSerializer = Serial::Serializer.new do |h, project| 71 | h.map(:assignments, project.assignments) do |h, assignment| 72 | h.attribute(:id, assignment.id) 73 | h.attribute(:duration, assignment.duration) 74 | end 75 | end # => { "assignments" => [{ "id" => …, "duration" => … }, …] } 76 | ``` 77 | 78 | The low-level interface powering `#map` is `#collection`. 79 | 80 | ``` ruby 81 | ProjectSerializer = Serial::Serializer.new do |h, project| 82 | h.collection(:indices) do |l| 83 | l.element { |h| h.attribute(…) } 84 | l.element { |h| h.attribute(…) } 85 | 86 | l.collection do |l| 87 | l.element { … } 88 | l.element { … } 89 | end 90 | end 91 | end # => { "indices" => [{…}, {…}, [{…}, {…}]] } 92 | ``` 93 | 94 | ### Merging 95 | 96 | `#merge` will let you merge another serializer without introducing a new nesting level. 97 | 98 | ``` ruby 99 | ProjectSerializer = Serial::Serializer.new do |h, project| 100 | h.attribute(:name, project.name) 101 | end # => { "name" => … } 102 | 103 | FullProjectSerializer = Serial::Serializer.new do |h, project| 104 | h.merge(project, &ProjectSerializer) 105 | h.attribute(:description, project.description) 106 | end # { "name" => …, "description" => … } 107 | ``` 108 | 109 | ### Composition 110 | 111 | You can compose serializers by passing them as blocks to the DSL methods. 112 | 113 | ``` ruby 114 | PersonSerializer = Serial::Serializer.new do |h, person| 115 | h.attribute(:name, person.name) 116 | end # => { "name" => … } 117 | 118 | ProjectSerializer = Serial::Serializer.new do |h, project| 119 | h.attribute(:owner, project.owner, &PersonSerializer) 120 | h.map(:people, project.people, &PersonSerializer) 121 | end # { "owner" => { "name" => … }, "people" => [{ "name" => … }, …] } 122 | ``` 123 | 124 | ## Using your serializers 125 | 126 | *Full reference: [Serial::Serializer](http://www.rubydoc.info/gems/serial/Serial/Serializer).* 127 | 128 | - The context parameter in the below examples (when using `#call` and `#map`) is optional, if not provided regular block scoping rules apply. 129 | - Tip: include [Serial::RailsHelpers](http://www.rubydoc.info/gems/serial/Serial/RailsHelpers) in ApplicationController for a convenient `#serialize` method. 130 | 131 | ### Serializing a single object 132 | 133 | ``` ruby 134 | project = Project.find(…) 135 | context = self 136 | ProjectSerializer.call(context, project) # => { … } 137 | ``` 138 | 139 | ### Serializing a list of objects 140 | 141 | ``` ruby 142 | projects = Project.all 143 | context = self 144 | ProjectSerializer.map(context, projects) # => [{ … }, …] 145 | ``` 146 | 147 | ### Using with Rails 148 | 149 | ```ruby 150 | # app/serializers/project_serializer.rb 151 | ProjectSerializer = Serial::Serializer.new do |h, project| 152 | … 153 | end 154 | ``` 155 | 156 | ``` ruby 157 | # app/controllers/project_controller.rb 158 | class ProjectController < ApplicationController 159 | include Serial::RailsHelpers 160 | 161 | def show 162 | project = Project.find(…) 163 | 164 | # 1. Using helper from RailsHelpers. 165 | render json: serialize(project) 166 | 167 | # 2. Explicitly mentioning serializer using helper method. 168 | render json: serialize(project, &ProjectSerializer) 169 | 170 | # 3. Explicitly mentioning serializer. 171 | render json: ProjectSerializer.call(self, project) 172 | end 173 | end 174 | ``` 175 | 176 | ## Development 177 | 178 | After checking out the repo, run `bin/setup` to install dependencies. Then, run 179 | `rake spec` to run the tests. You can also run `bin/console` for an interactive 180 | prompt that will allow you to experiment. 181 | 182 | To install this gem onto your local machine, run `bundle exec rake install`. To 183 | release a new version, update the version number in `version.rb`, and then run 184 | `bundle exec rake release`, which will create a git tag for the version, push 185 | git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 186 | 187 | ## Contributing 188 | 189 | Bug reports and pull requests are welcome on GitHub at 190 | https://github.com/elabs/serial. This project is intended to be a safe, 191 | welcoming space for collaboration, and contributors are expected to adhere to 192 | the [Contributor Covenant](CODE_OF_CONDUCT.md) code of conduct. 193 | 194 | ## License 195 | 196 | The gem is available as open source under the terms of the 197 | [MIT License](http://opensource.org/licenses/MIT). 198 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | task :release => :spec # BEFORE bundler gem tasks. 2 | 3 | require "bundler/gem_tasks" 4 | 5 | require "rspec/core/rake_task" 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | require "yard" 9 | YARD::Rake::YardocTask.new(:doc) 10 | 11 | task :default => :spec 12 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "serial" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | require "pry" 10 | Pry.start 11 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /elabs-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/varvet/serial/52d5b19865293027d3c3253cd32b489a90dd9e41/elabs-logo.png -------------------------------------------------------------------------------- /lib/serial.rb: -------------------------------------------------------------------------------- 1 | require "serial/version" 2 | require "serial/serializer" 3 | require "serial/builder" 4 | require "serial/hash_builder" 5 | require "serial/array_builder" 6 | require "serial/rails_helpers" 7 | 8 | # Serial namespace. See {Serial::Serializer} for reference. 9 | module Serial 10 | # All serial-specific errors inherit from this error. 11 | class Error < StandardError 12 | end 13 | 14 | # Raised when an already-defined key is defined again. 15 | class DuplicateKeyError < StandardError 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/serial/array_builder.rb: -------------------------------------------------------------------------------- 1 | module Serial 2 | # A builder for building arrays. You most likely just want to look at the 3 | # public API methods in this class. 4 | class ArrayBuilder < Builder 5 | # @api private 6 | def initialize(context) 7 | @context = context 8 | @data = [] 9 | end 10 | 11 | # @api public 12 | # Serializes a hash item in a collection. 13 | # 14 | # @example 15 | # h.collection(…) do |l| 16 | # l.element do |h| 17 | # h.attribute(…) 18 | # end 19 | # end 20 | # 21 | # @yield [builder] 22 | # @yieldparam builder [HashBuilder] 23 | def element(value = :__not_used__, &block) 24 | if value == :__not_used__ 25 | @data << HashBuilder.build(@context, &block) 26 | elsif not block 27 | @data << value 28 | else 29 | raise ArgumentError, "cannot pass both a block and an argument to `element`" 30 | end 31 | end 32 | 33 | # @api public 34 | # Serializes a collection in a collection. 35 | # 36 | # @example 37 | # h.collection(…) do |l| 38 | # l.collection do |l| 39 | # l.element { … } 40 | # end 41 | # end 42 | # 43 | # @yield [builder] 44 | # @yieldparam builder [ArrayBuilder] 45 | def collection(&block) 46 | @data << ArrayBuilder.build(@context, &block) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/serial/builder.rb: -------------------------------------------------------------------------------- 1 | module Serial 2 | # @api private 3 | # 4 | # Builder contains common methods to the serializer DSL. 5 | class Builder 6 | # Create the builder, execute the block inside it, and return its' data. 7 | # Any superflous arguments are given to {#exec}. 8 | # 9 | # @param context [#instance_exec, nil] the context to execute block inside 10 | # @yield (see #exec) 11 | # @yieldparam (see #exec) 12 | # @return [#data] 13 | def self.build(context, *args, &block) 14 | builder = new(context) 15 | builder.exec(*args, &block) 16 | builder.data 17 | end 18 | 19 | # Builder data, depends on what kind of builder it is. 20 | # 21 | # @return [Array, Hash] 22 | attr_reader :data 23 | 24 | # Executes a block in the configured context, if there is one, otherwise using regular closure scoping. 25 | # 26 | # 27 | # @yield [self, *args] 28 | # @yieldparam self [Builder] passes in self as the first parameter. 29 | # @yieldparam *args superflous arguments are passed to the block. 30 | def exec(*args, &block) 31 | if @context 32 | @context.instance_exec(self, *args, &block) 33 | elsif block 34 | block.call(self, *args) 35 | else 36 | raise ArgumentError, "no serializer block given" 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/serial/hash_builder.rb: -------------------------------------------------------------------------------- 1 | module Serial 2 | # A builder for building hashes. You most likely just want to look at the 3 | # public API methods in this class. 4 | class HashBuilder < Builder 5 | # @api private 6 | def initialize(context) 7 | @context = context 8 | @data = {} 9 | end 10 | 11 | # @api public 12 | # Declare an attribute. 13 | # 14 | # @example without block 15 | # h.attribute(:id, 5) # => { "id" => 5 } 16 | # 17 | # @example nested attribute, with block 18 | # h.attribute(:project, project) do |h, project| 19 | # h.attribute(:name, project.name) 20 | # end # => { "project" => { "name" => … } } 21 | # 22 | # @param key [#to_s] 23 | # @param value 24 | # @yield [builder, value] declare nested attribute if block is given 25 | # @yieldparam builder [HashBuilder] (keep in mind the examples shadow the outer `h` variable) 26 | # @yieldparam value 27 | # @raise [DuplicateKeyError] if the same key has already been defined. 28 | def attribute(key, value = nil, &block) 29 | check_duplicate_key!(key) 30 | attribute!(key, value, &block) 31 | end 32 | 33 | # @api public 34 | # Same as {#attribute}, but will not raise an error on duplicate keys. 35 | # 36 | # @see #attribute 37 | # @param (see #attribute) 38 | # @yield (see #attribute) 39 | # @yieldparam (see #attribute) 40 | def attribute!(key, value = nil, &block) 41 | value = HashBuilder.build(@context, value, &block) if block 42 | @data[key.to_s] = value 43 | end 44 | 45 | # @api public 46 | # Declare a collection attribute. This is a low-level method, see {#map} instead. 47 | # 48 | # @example 49 | # h.collection(:people) do |l| 50 | # l.element do |h| 51 | # h.attribute(…) 52 | # end 53 | # l.element do |h| 54 | # h.attribute(…) 55 | # end 56 | # l.collection do |l| 57 | # l.element do |h| 58 | # h.attribute(…) 59 | # end 60 | # end 61 | # end # => { "people" => [{…}, {…}, [{…}]] } 62 | # 63 | # @see ArrayBuilder 64 | # @param key [#to_s] 65 | # @yield [builder] 66 | # @yieldparam builder [ArrayBuilder] 67 | def collection(key, &block) 68 | check_duplicate_key!(key) 69 | collection!(key, &block) 70 | end 71 | 72 | # @api public 73 | # Same as {#collection}, but will not raise an error on duplicate keys. 74 | # 75 | # @see #collection 76 | # @param (see #collection) 77 | # @yield (see #collection) 78 | # @yieldparam (see #collection) 79 | def collection!(key, &block) 80 | attribute!(key, ArrayBuilder.build(@context, &block)) 81 | end 82 | 83 | # @api public 84 | # Declare a collection attribute from a list of values. 85 | # 86 | # @example 87 | # h.map(:people, project.people) do |h, person| 88 | # h.attribute(:name, person.name) 89 | # end # => { "people" => [{ "name" => … }] } 90 | # 91 | # @see #collection 92 | # @param key [#to_s] 93 | # @param list [#each] 94 | # @yield [builder, value] yields each value from list to build an array of hashes 95 | # @yieldparam builder [HashBuilder] 96 | # @yieldparam value 97 | def map(key, list, &block) 98 | check_duplicate_key!(key) 99 | map!(key, list, &block) 100 | end 101 | 102 | # @api public 103 | # Same as {#map}, but will not raise an error on duplicate keys. 104 | # 105 | # @see #map 106 | # @param (see #map) 107 | # @yield (see #map) 108 | # @yieldparam (see #map) 109 | def map!(key, list, &block) 110 | collection!(key) do |builder| 111 | list.each do |item| 112 | builder.element do |element| 113 | element.exec(item, &block) 114 | end 115 | end 116 | end 117 | end 118 | 119 | # @api public 120 | # Merge another serializer into the current serialization. 121 | # 122 | # @example 123 | # ExtendedProjectSerializer = Serial::Serializer.new do |h, project| 124 | # h.merge(project, &ProjectSerializer) 125 | # h.attribute(:extra, project.extra_info) 126 | # end # => { "name" => …, …, "extra" => … } 127 | # 128 | # @param value 129 | # @yield [builder, value] to another serializer 130 | # @yieldparam builder [HashBuilder] 131 | # @yieldparam value 132 | # @raise [DuplicateKeyError] if a key to be merged is already defined. 133 | def merge(value, &serializer) 134 | hash = HashBuilder.build(@context, value, &serializer) 135 | hash.keys.each { |key| check_duplicate_key!(key) } 136 | @data.merge!(hash) 137 | end 138 | 139 | # @api public 140 | # Same as {#merge}, but will not raise an error on duplicate keys. 141 | # 142 | # @see #merge 143 | # @param (see #merge) 144 | # @yield (see #merge) 145 | # @yieldparam (see #merge) 146 | def merge!(value, &serializer) 147 | hash = HashBuilder.build(@context, value, &serializer) 148 | @data.merge!(hash) 149 | end 150 | 151 | private 152 | 153 | # @param key [#to_s] 154 | # @raise [DuplicateKeyError] if key is defined 155 | # @return [nil] 156 | def check_duplicate_key!(key) 157 | if @data.has_key?(key.to_s) 158 | raise DuplicateKeyError, "'#{key}' is already defined" 159 | end 160 | end 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /lib/serial/rails_helpers.rb: -------------------------------------------------------------------------------- 1 | module Serial 2 | # Helpers for using Serial with Rails. 3 | module RailsHelpers 4 | # @api public 5 | # Find the serializer for `model` and serialize it in the context of self. 6 | # 7 | # @example serializing a single object 8 | # render json: { person: serialize(Person.first) } 9 | # 10 | # @example serializing multiple objects 11 | # render json: { people: serialize(Person.all) } 12 | # 13 | # @example serializing with explicit context 14 | # render json: { people: serialize(presenter, Person.all) } 15 | # 16 | # @example serializing with explicit serializer 17 | # render json: { people: serialize(Person.all, &my_serializer) } 18 | # 19 | # @param context [#instance_exec] 20 | # @param model [#model_name, #each?] 21 | # @yield [builder, model] yields if a block is given to use a custom serializer 22 | # @yieldparam builder [HashBuilder] 23 | def serialize(context = view_context, model, &serializer) 24 | serializer &&= Serializer.new(&serializer) 25 | serializer ||= "#{model.model_name}Serializer".constantize 26 | 27 | if model.respond_to?(:each) 28 | serializer.map(context, model) 29 | else 30 | serializer.call(context, model) 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/serial/serializer.rb: -------------------------------------------------------------------------------- 1 | module Serial 2 | # Using this class you build serializers. 3 | class Serializer 4 | # Create a new Serializer, using the block as instructions. 5 | # 6 | # @example 7 | # # app/serializers/person_serializer.rb 8 | # PersonSerializer = Serial::Serializer.new do |h, person| 9 | # h.attribute(:name, person.name) 10 | # end 11 | # 12 | # @yield [builder, value] 13 | # @yieldparam builder [HashBuilder] 14 | # @yieldparam value from {#call} or {#map} 15 | def initialize(&block) 16 | unless block_given? 17 | raise ArgumentError, "instructions (block) is required" 18 | end 19 | 20 | @block = block 21 | @to_proc = method(:to_proc_implementation).to_proc 22 | end 23 | 24 | # Serialize an object with this serializer, optionally within a context. 25 | # 26 | # @example with context, the serializer block is evaluated inside the context 27 | # # app/serializers/person_serializer.rb 28 | # PersonSerializer = Serial::Serializer.new do |h, person| 29 | # h.attribute(:id, person.id) 30 | # h.attribute(:url, people_url(person)) 31 | # end 32 | # 33 | # # app/controllers/person_controller.rb 34 | # def show 35 | # person = Person.find(…) 36 | # render json: PersonSerializer.call(self, person) 37 | # end 38 | # 39 | # @example without context, the serializer block is evaluated using normal closure rules 40 | # # app/models/person.rb 41 | # class Person 42 | # Serializer = Serial::Serializer.new do |h, person| 43 | # h.attribute(:id, person.id) 44 | # h.attribute(:available_roles, available_roles) 45 | # end 46 | # 47 | # def self.available_roles 48 | # … 49 | # end 50 | # end 51 | # 52 | # # app/controllers/person_controller.rb 53 | # def show 54 | # person = Person.find(…) 55 | # render json: Person::Serializer.call(person) 56 | # end 57 | # 58 | # @param context [#instance_exec, nil] context to execute serializer in, or nil to use regular block closure rules. 59 | # @param value 60 | # @return [Hash] 61 | def call(context = nil, value) 62 | HashBuilder.build(context, value, &@block) 63 | end 64 | 65 | # Serialize a list of objects with this serializer, optionally within a context. 66 | # 67 | # @example 68 | # # app/serializers/person_serializer.rb 69 | # PersonSerializer = Serial::Serializer.new do |h, person| 70 | # h.attribute(:id, person.id) 71 | # h.attribute(:url, people_url(person)) 72 | # end 73 | # 74 | # # app/controllers/person_controller.rb 75 | # def index 76 | # people = Person.all 77 | # render json: PersonSerializer.map(self, people) 78 | # end 79 | # 80 | # @see #call see #call for an explanation of the context parameter 81 | # @param context (see #call) 82 | # @param list [#each] 83 | # @return [Array] 84 | def map(context = nil, list) 85 | values = [] 86 | list.each { |item| values << call(context, item) } 87 | values 88 | end 89 | 90 | # Serializer composition! 91 | # 92 | # @example 93 | # # app/serializers/person_serializer.rb 94 | # PersonSerializer = Serial::Serializer.new do |h, person| 95 | # h.attribute(:name, person.name) 96 | # end 97 | # 98 | # # app/serializers/project_serializer.rb 99 | # ProjectSerializer = Serial::Serializer.new do |h, project| 100 | # h.attribute(:owner, project.owner, &PersonSerializer) 101 | # h.map(:people, project.people, &PersonSerializer) 102 | # end 103 | # 104 | # @return [Proc] 105 | attr_reader :to_proc 106 | 107 | private 108 | 109 | def to_proc_implementation(builder, *args) 110 | builder.exec(*args, &@block) 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /lib/serial/version.rb: -------------------------------------------------------------------------------- 1 | module Serial 2 | # Gem version, uses SemVer. 3 | VERSION = "1.0.0" 4 | end 5 | -------------------------------------------------------------------------------- /serial.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'serial/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "serial" 8 | spec.version = Serial::VERSION 9 | spec.authors = ["Elabs", "Jonas Nicklas", "Kim Burgestrand"] 10 | spec.email = ["dev@elabs.se", "jonas@elabs.se", "kim@elabs.se"] 11 | spec.license = "MIT" 12 | 13 | spec.summary = %q{Plain old Ruby for generating primitive data structures from object graphs.} 14 | spec.homepage = "https://github.com/elabs/serial" 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 17 | spec.bindir = "exe" 18 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_development_dependency "bundler", "~> 1.10" 22 | spec.add_development_dependency "pry", "~> 0.10" 23 | spec.add_development_dependency "rake", "~> 10.0" 24 | spec.add_development_dependency "rspec", "~> 3.2" 25 | spec.add_development_dependency "yard", ">= 0.9.11" 26 | spec.add_development_dependency "activerecord", "~> 4.0" 27 | end 28 | -------------------------------------------------------------------------------- /spec/serial/dsl_spec.rb: -------------------------------------------------------------------------------- 1 | describe "Serial DSL" do 2 | def serialize(subject = nil, &block) 3 | Serial::Serializer.new(&block).call(nil, subject) 4 | end 5 | 6 | describe "HashBuilder" do 7 | let(:report_serializer) do 8 | Serial::Serializer.new do |h, subject| 9 | h.attribute(:name, subject) 10 | end 11 | end 12 | 13 | describe "#attribute" do 14 | it "serializes simple attributes" do 15 | data = serialize { |h| h.attribute(:hello, "World") } 16 | 17 | expect(data).to eq({ "hello" => "World" }) 18 | end 19 | 20 | it "serializes nested attributes" do 21 | data = serialize do |h| 22 | h.attribute(:hi) { |h| h.attribute(:hello, "World") } 23 | end 24 | 25 | expect(data).to eq({ "hi" => { "hello" => "World" } }) 26 | end 27 | 28 | it "forwards value when serializing simple attributes" do 29 | data = serialize("World") { |h, x| h.attribute(:hello, x) } 30 | 31 | expect(data).to eq({ "hello" => "World" }) 32 | end 33 | 34 | it "forwards value when serializing nested attributes" do 35 | data = serialize do |h| 36 | h.attribute(:hi, "World") { |h, x| h.attribute(:hello, x) } 37 | end 38 | 39 | expect(data).to eq({ "hi" => { "hello" => "World" } }) 40 | end 41 | 42 | it "explodes if the attribute already exists" do 43 | serializer = Serial::Serializer.new do |h| 44 | h.attribute(:hi, "a") 45 | h.attribute(:hi, "b") 46 | end 47 | 48 | expect { serializer.call(nil) }.to raise_error(Serial::DuplicateKeyError, "'hi' is already defined") 49 | end 50 | end 51 | 52 | describe "#map" do 53 | it "serializes a list of values" do 54 | data = serialize do |h| 55 | h.map(:numbers, ["a", "b", "c"]) do |h, x| 56 | h.attribute(:x, x) 57 | h.attribute(:X, x.upcase) 58 | end 59 | end 60 | 61 | expect(data).to eq({ 62 | "numbers" => [ 63 | { "x" => "a", "X" => "A" }, 64 | { "x" => "b", "X" => "B" }, 65 | { "x" => "c", "X" => "C" }, 66 | ] 67 | }) 68 | end 69 | 70 | it "explodes if the attribute already exists" do 71 | serializer = Serial::Serializer.new do |h| 72 | h.attribute(:hi, "a") 73 | h.map(:hi, [1]) do |h, id| 74 | h.attribute(:id, id) 75 | end 76 | end 77 | 78 | expect { serializer.call(nil) }.to raise_error(Serial::DuplicateKeyError, "'hi' is already defined") 79 | end 80 | end 81 | 82 | describe "#collection" do 83 | it "serializes a collection" do 84 | data = serialize do |h| 85 | h.collection(:numbers) do |l| 86 | end 87 | end 88 | 89 | expect(data).to eq({ "numbers" => [] }) 90 | end 91 | 92 | it "explodes if the attribute already exists" do 93 | serializer = Serial::Serializer.new do |h| 94 | h.attribute(:hi, "a") 95 | h.collection(:hi) do |l| 96 | l.element do |h| 97 | h.attribute(:id, 1) 98 | end 99 | end 100 | end 101 | 102 | expect { serializer.call(nil) }.to raise_error(Serial::DuplicateKeyError, "'hi' is already defined") 103 | end 104 | end 105 | 106 | describe "#merge" do 107 | it "merges a serializer into the current scope" do 108 | data = serialize do |h| 109 | h.attribute(:extended, "Extended") 110 | h.merge("Hi", &report_serializer) 111 | end 112 | 113 | expect(data).to eq({ "name" => "Hi", "extended" => "Extended" }) 114 | end 115 | 116 | it "explodes if a merged attribute already exists" do 117 | full_report_serializer = Serial::Serializer.new do |h| 118 | h.attribute(:name, "Replaced") 119 | h.attribute(:extended, "Extended") 120 | h.merge("Hi", &report_serializer) 121 | end 122 | 123 | expect { full_report_serializer.call(nil) }.to raise_error(Serial::DuplicateKeyError, "'name' is already defined") 124 | end 125 | end 126 | 127 | describe "!-methods" do 128 | describe "#attribute!" do 129 | it "does not explode if the attribute already exists" do 130 | serializer = Serial::Serializer.new do |h| 131 | h.attribute(:hi, "a") 132 | h.attribute!(:hi, "b") 133 | end 134 | 135 | expect(serializer.call(nil)).to eq({ "hi" => "b" }) 136 | end 137 | end 138 | 139 | describe "#map!" do 140 | it "does not explode if the attribute already exists" do 141 | serializer = Serial::Serializer.new do |h| 142 | h.attribute(:hi, "a") 143 | h.map!(:hi, [1]) do |h, id| 144 | h.attribute(:id, id) 145 | end 146 | end 147 | 148 | expect(serializer.call(nil)).to eq({ "hi" => [{ "id" => 1 }] }) 149 | end 150 | end 151 | 152 | describe "#collection!" do 153 | it "does not explode if the attribute already exists" do 154 | serializer = Serial::Serializer.new do |h| 155 | h.attribute(:hi, "a") 156 | h.collection!(:hi) do |l| 157 | l.element do |h| 158 | h.attribute(:id, 1) 159 | end 160 | end 161 | end 162 | 163 | expect(serializer.call(nil)).to eq({ "hi" => [{ "id" => 1 }] }) 164 | end 165 | end 166 | 167 | describe "#merge!" do 168 | it "does not explode if a merged attribute already exists" do 169 | full_report_serializer = Serial::Serializer.new do |h| 170 | h.attribute(:name, "Replaced") 171 | h.attribute(:extended, "Extended") 172 | h.merge!("Hi", &report_serializer) 173 | end 174 | 175 | expect(full_report_serializer.call(nil)).to eq({ "name" => "Hi", "extended" => "Extended" }) 176 | end 177 | end 178 | end 179 | end 180 | 181 | describe "ArrayBuilder" do 182 | def collection(&block) 183 | serialize { |h| h.collection(:collection, &block) } 184 | end 185 | 186 | describe "#element" do 187 | it "serializes a hash in a collection" do 188 | data = collection do |l| 189 | l.element { |h| h.attribute(:hello, "World") } 190 | l.element { |h| h.attribute(:hi, "There") } 191 | end 192 | 193 | expect(data).to eq({ 194 | "collection" => [ 195 | { "hello" => "World" }, 196 | { "hi" => "There" } 197 | ] 198 | }) 199 | end 200 | 201 | it "accepts a value" do 202 | data = collection do |l| 203 | l.element("hello") 204 | l.element("world") 205 | end 206 | 207 | expect(data).to eq({ 208 | "collection" => ["hello", "world"] 209 | }) 210 | end 211 | 212 | it "raises an error when both block and value given" do 213 | expect do 214 | collection do |l| 215 | l.element("hello") { |h| h.attribute(:foo, "bar") } 216 | end 217 | end.to raise_error(ArgumentError, "cannot pass both a block and an argument to `element`") 218 | end 219 | end 220 | 221 | describe "#collection" do 222 | it "serializes a collection inside of a collection" do 223 | data = collection do |l| 224 | l.collection do |l| 225 | l.element { |h| h.attribute(:hello, "World") } 226 | l.element { |h| h.attribute(:hi, "There") } 227 | end 228 | 229 | l.collection do |l| 230 | l.element { |h| h.attribute(:piff, "Puff") } 231 | end 232 | end 233 | 234 | expect(data).to eq({ 235 | "collection" => [ 236 | [{ "hello" => "World" }, { "hi" => "There" }], 237 | [{ "piff" => "Puff" }] 238 | ] 239 | }) 240 | end 241 | end 242 | end 243 | end 244 | -------------------------------------------------------------------------------- /spec/serial/rails_helpers_spec.rb: -------------------------------------------------------------------------------- 1 | require "active_record" 2 | 3 | adapter = if RUBY_ENGINE == "jruby" 4 | "jdbcsqlite3" 5 | else 6 | "sqlite3" 7 | end 8 | 9 | ActiveRecord::Base.establish_connection(adapter: adapter, database: ":memory:") 10 | ActiveRecord::Schema.define do 11 | self.verbose = false 12 | create_table :fake_people, :force => true do |t| 13 | t.string :name 14 | end 15 | end 16 | 17 | # This should be an active record model for model_name testing on scopes. 18 | class FakePerson < ActiveRecord::Base 19 | end 20 | 21 | # This must be a constant, so that it can be looked up using constantize. 22 | FakePersonSerializer = Serial::Serializer.new do |h, person| 23 | h.attribute(:name, person.name) 24 | h.attribute(:url, person_url(person)) 25 | end 26 | 27 | class FakeContext 28 | def person_url(person) 29 | "/fake/#{person.name.downcase}" 30 | end 31 | end 32 | 33 | describe Serial::RailsHelpers do 34 | include Serial::RailsHelpers 35 | 36 | def view_context 37 | self 38 | end 39 | 40 | # Simulate having a route helper in the controller scope (self). 41 | def person_url(person) 42 | "/people/#{person.name.downcase}" 43 | end 44 | 45 | around do |example| 46 | ActiveRecord::Base.connection.transaction do 47 | example.run 48 | raise ActiveRecord::Rollback 49 | end 50 | end 51 | 52 | describe "#serialize" do 53 | let(:custom_serializer) do 54 | Serial::Serializer.new do |h, person| 55 | h.attribute(:rawrwrwr, person.name) 56 | h.attribute(:url, person_url(person)) 57 | end 58 | end 59 | 60 | describe "a single model" do 61 | let(:person) { FakePerson.create!(name: "Yngve") } 62 | 63 | it "serializes a single person in the controller context" do 64 | expect(serialize(person)).to eq({ "name" => "Yngve", "url" => "/people/yngve" }) 65 | end 66 | 67 | it "allows overriding the context" do 68 | expect(serialize(FakeContext.new, person)).to eq({ "name" => "Yngve", "url" => "/fake/yngve" }) 69 | end 70 | 71 | it "accepts the serializer as a block" do 72 | expect(serialize(person, &custom_serializer)).to eq({ "rawrwrwr" => "Yngve", "url" => "/people/yngve" }) 73 | end 74 | 75 | it "allows overriding the context with an overridden serializer" do 76 | expect(serialize(FakeContext.new, person, &custom_serializer)).to eq({ "rawrwrwr" => "Yngve", "url" => "/fake/yngve" }) 77 | end 78 | end 79 | 80 | describe "a list of models" do 81 | before do 82 | FakePerson.create!(name: "Yngve") 83 | FakePerson.create!(name: "Ylva") 84 | end 85 | 86 | # Using ActiveRecord scope here, it's important. 87 | let(:people) { FakePerson.order(:name).all } 88 | 89 | it "serializes multiple people in the controller context" do 90 | expect(serialize(people)).to eq([ 91 | { "name" => "Ylva", "url" => "/people/ylva" }, 92 | { "name" => "Yngve", "url" => "/people/yngve" }, 93 | ]) 94 | end 95 | 96 | it "allows overriding the context" do 97 | expect(serialize(FakeContext.new, people)).to eq([ 98 | { "name" => "Ylva", "url" => "/fake/ylva" }, 99 | { "name" => "Yngve", "url" => "/fake/yngve" }, 100 | ]) 101 | end 102 | 103 | it "accepts the serializer as a block" do 104 | expect(serialize(people, &custom_serializer)).to eq([ 105 | { "rawrwrwr" => "Ylva", "url" => "/people/ylva" }, 106 | { "rawrwrwr" => "Yngve", "url" => "/people/yngve" }, 107 | ]) 108 | end 109 | 110 | it "allows overriding the context with an overridden serializer" do 111 | expect(serialize(FakeContext.new, people, &custom_serializer)).to eq([ 112 | { "rawrwrwr" => "Ylva", "url" => "/fake/ylva" }, 113 | { "rawrwrwr" => "Yngve", "url" => "/fake/yngve" }, 114 | ]) 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /spec/serial/serializer_spec.rb: -------------------------------------------------------------------------------- 1 | class FakeContext 2 | def dr_prefix(name) 3 | "Dr. #{name}" 4 | end 5 | end 6 | 7 | describe Serial::Serializer do 8 | def dr_prefix(name) 9 | "Doctor #{name}" 10 | end 11 | 12 | let(:person_with_friend) do 13 | double(name: "Kim", friend: double(name: "Jonas", friend: nil)) 14 | end 15 | 16 | let(:other_person_with_friend) do 17 | double(name: "Piff", friend: double(name: "Puff", friend: nil)) 18 | end 19 | 20 | let(:friend_serializer) do 21 | Serial::Serializer.new do |h, person| 22 | h.attribute(:name, dr_prefix(person.name)) 23 | h.attribute(:friend, person.friend) do |h, friend| 24 | h.attribute(:name, dr_prefix(friend.name)) 25 | end 26 | end 27 | end 28 | 29 | describe "#initialize" do 30 | it "raises an error if block is not provided" do 31 | expect { Serial::Serializer.new }.to raise_error(ArgumentError, /block/) 32 | end 33 | end 34 | 35 | describe "#call" do 36 | specify "without context the serializer is executed inside the context" do 37 | expect(friend_serializer.call(person_with_friend)).to eq({ 38 | "name" => "Doctor Kim", 39 | "friend" => { "name" => "Doctor Jonas" } 40 | }) 41 | end 42 | 43 | specify "with context the serializer is executed inside the context" do 44 | expect(friend_serializer.call(FakeContext.new, person_with_friend)).to eq({ 45 | "name" => "Dr. Kim", 46 | "friend" => { "name" => "Dr. Jonas" } 47 | }) 48 | end 49 | end 50 | 51 | describe "#map" do 52 | let(:people) { [person_with_friend, other_person_with_friend] } 53 | 54 | specify "without context the serializer is executed using normal closure rules" do 55 | expect(friend_serializer.map(people)).to eq([ 56 | { 57 | "name" => "Doctor Kim", 58 | "friend" => { "name" => "Doctor Jonas" } 59 | }, 60 | { 61 | "name" => "Doctor Piff", 62 | "friend" => { "name" => "Doctor Puff" } 63 | }, 64 | ]) 65 | end 66 | 67 | specify "with context the serializer is executed inside the context" do 68 | expect(friend_serializer.map(FakeContext.new, people)).to eq([ 69 | { 70 | "name" => "Dr. Kim", 71 | "friend" => { "name" => "Dr. Jonas" } 72 | }, 73 | { 74 | "name" => "Dr. Piff", 75 | "friend" => { "name" => "Dr. Puff" } 76 | }, 77 | ]) 78 | end 79 | end 80 | 81 | describe "#to_proc" do 82 | let(:evil) do 83 | double(title: "Evil", minion: person_with_friend) 84 | end 85 | 86 | let(:composed_serializer) do 87 | other_serializer = friend_serializer # => for context-test we require local variable. 88 | Serial::Serializer.new do |h, master| 89 | h.attribute(:title, dr_prefix(master.title)) 90 | h.attribute(:minion, master.minion, &other_serializer) 91 | end 92 | end 93 | 94 | it "allows serializer composition" do 95 | expect(composed_serializer.call(evil)).to eq({ 96 | "title" => "Doctor Evil", 97 | "minion" => { 98 | "name" => "Doctor Kim", 99 | "friend" => { "name" => "Doctor Jonas" } 100 | } 101 | }) 102 | end 103 | 104 | specify "with context the serializer is executed inside the context" do 105 | expect(composed_serializer.call(FakeContext.new, evil)).to eq({ 106 | "title" => "Dr. Evil", 107 | "minion" => { 108 | "name" => "Dr. Kim", 109 | "friend" => { "name" => "Dr. Jonas" } 110 | } 111 | }) 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /spec/serial_spec.rb: -------------------------------------------------------------------------------- 1 | describe Serial do 2 | it "has a version number" do 3 | expect(Serial::VERSION).not_to be nil 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "serial" 3 | --------------------------------------------------------------------------------