PropsTemplate
PropsTemplate is a direct-to-Oj, JBuilder-like DSL for building JSON. It has support for Russian-Doll caching, layouts, and can be queried by giving the root a key path.
It's fast.
PropsTemplate bypasses the steps of hash building and serializing
that other libraries perform by using Oj's StringWriter
in rails
mode.
Caching is fast too.
While other libraries spend time unmarshaling, merging hashes, and serializing to JSON; PropsTemplate simply takes the cached string and uses Oj's push_json.
Example:
PropsTemplate is very similar to JBuilder, and selectively retains some conveniences and magic.
json.flash flash.to_h
json.menu do
json.currentUser do
json.email current_user.email
json.avatar current_user.avatar
json.inbox current_user.messages.count
end
end
json.dashboard(defer: :auto) do
sleep 5
json.complexPostMetric 500
end
json.posts do
page_num = params[:page_num]
paged_posts = @posts.page(page_num).per(20)
json.list do
json.array! paged_posts, key: :id do |post|
json.id post.id
json.description post.description
json.commentsCount post.comments.count
json.editPath edit_post_path(post)
end
end
json.paginationPath posts_path
json.current pagedPosts.current_page
json.total @posts.count
end
json.footer partial: 'shared/footer' do
end
Installation
gem 'props_template'
and run bundle
.
Optionally add the core ext to an initializer if you want to dig into your templates.
require 'props_template/core_ext'
And create a file in your app/views
folder like so:
# app/views/posts/index.json.props
json.greetings "hello world"
You can also add a layout.
API
json.set! or json.<your key here>
Defines the attribute or structure. All keys are not formatted by default. See Change Key Format to change this behavior.
json.set! :authorDetails, {...options} do
json.set! :firstName, 'David'
end
or
json.authorDetails, {...options} do
json.firstName 'David'
end
# => {"authorDetails": { "firstName": "David" }}
The inline form defines key and value
Parameter | Notes |
---|---|
key | A json object key |
value | A value |
json.set! :firstName, 'David'
or
json.firstName 'David'
# => { "firstName": "David" }
The block form defines key and structure
Parameter | Notes |
---|---|
key | A json object key |
options | Additional options |
block | Additional json.set! s or json.array! s |
json.set! :details do
...
end
or
json.details do
...
end
The difference between the block form and inline form is
- The block form is an internal node. Functionality such as Partials, Deferment and other options are only available on the block form.
- The inline form is considered a leaf node, and you can only search for internal nodes.
json.extract!
Extracts attributes from object or hash in 1 line
# without extract!
json.id user.id
json.email user.email
json.firstName user.first_name
# with extract!
json.extract! user, :id, :email, :first_name
# => {"id" => 1, "email" => "email@gmail.com", "first_name" => "user"}
# with extract! with key transformation
json.extract! user, :id, [:first_name, :firstName], [:last_name, :lastName]
# => {"id" => 1, "firstName" => "user", "lastName" => "last"}
The inline form defines object and attributes
Parameter | Notes |
---|---|
object | An object |
attributes | A list of attributes |
json.array!
Generates an array of json objects.
collection = [ {name: 'john'}, {name: 'jim'} ]
json.details do
json.array! collection, {...options} do |person|
json.firstName person[:name]
end
end
# => {"details": [{"firstName": 'john'}, {"firstName": 'jim'} ]}
Parameter | Notes |
---|---|
collection | A collection that responds to member_at and member_by
|
options | Additional options |
To support traversing nodes, any list passed
to array!
MUST implement member_at(index)
and member_by(attr, value)
.
For example, if you were using a delegate:
class ObjectCollection < SimpleDelegator
def member_at(index)
at(index)
end
def member_by(attr, val)
find do |ele|
ele[attr] == val
end
end
end
Then in your template:
data = ObjectCollection.new([
{id: 1, name: 'foo'},
{id: 2, name: 'bar'}
])
json.array! data do
...
end
Similarly for ActiveRecord:
class ApplicationRecord < ActiveRecord::Base
def self.member_at(index)
offset(index).limit(1).first
end
def self.member_by(attr, value)
find_by(Hash[attr, val])
end
end
Then in your template:
json.array! Post.all do
...
end
Array core extension
For convenience, PropsTemplate includes a core_ext that adds these methods to
Array
. For example:
require 'props_template/core_ext'
data = [
{id: 1, name: 'foo'},
{id: 2, name: 'bar'}
]
json.posts
json.array! data do
...
end
end
PropsTemplate does not know what the elements are in your collection. The
example above will be fine for traversing
by index, but will raise a NotImplementedError
if you query by attribute. You
may still need to implement member_by
.
json.deferred!
Returns all deferred nodes used by the deferment option.
Note This is a SuperglueJS specific functionality and is used in
application.json.props
when first running rails superglue:install:web
json.deferred json.deferred!
# => [{url: '/some_url?props_at=outer.inner', path: 'outer.inner', type: 'auto'}]
This method provides metadata about deferred nodes to the frontend (SuperglueJS) to fetch missing data in a second round trip.
json.fragments!
Returns all fragment nodes used by the partial fragments option.
ruby json.fragments json.fragments!
Note This is a SuperglueJS specific functionality and is used in
application.json.props
when first running rails superglue:install:web
Options
Options Functionality such as Partials, Deferements, and Caching can only be set on a block. It is normal to see empty blocks.
json.post(partial: 'blog_post') do
end
Partials
Partials are supported. The following will render the file
views/posts/_blog_posts.json.props
, and set a local variable post
assigned
with @post, which you can use inside the partial.
json.one_post partial: ["posts/blog_post", locals: {post: @post}] do
end
Usage with arrays:
# The `as:` option is supported when using `array!`
# Without `as:` option you can use blog_post variable (name is based on partial's name) inside partial
json.posts do
json.array! @posts, partial: ["posts/blog_post", locals: {foo: 'bar'}, as: 'post'] do
end
end
Rendering partials without a key is also supported using json.partial!
, but use
sparingly! json.partial!
is not optimized for collection rendering and may
cause performance problems. Its best used for things like a shared header or footer.
Do:
json.partial! partial: "header", locals: {user: @user} do
end
or
json.posts do
json.array! @posts, partial: ["posts/blog_post", locals: {post: @post}] do
end
end
Do NOT:
@post.each do |post|
json.partial! partial: "post", locals: {post: @post} do
end
end
Partial Fragments
Note This is a SuperglueJS specific functionality.
A fragment identifies a partial output across multiple pages. It can be used to update cross cutting concerns like a header bar.
# index.json.props
json.header partial: ["profile", fragment: "header"] do
end
# _profile.json.props
json.profile do
json.address do
json.state "New York City"
end
end
When using fragments with Arrays, the argument MUST be a lamda:
require 'props_template/core_ext'
json.array! ['foo', 'bar'], partial: ["footer", fragment: ->(x){ x == 'foo'}] do
end
Caching
Caching is supported on internal nodes only. This limitation is what makes it possible to for props_template to forgo marshalling/unmarshalling and simply use push_json.
Usage:
json.author(cache: "some_cache_key") do
json.firstName "tommy"
end
#or
json.profile(cache: "cachekey", partial: ["profile", locals: {foo: 1}]) do
end
#or nest it
json.author(cache: "some_cache_key") do
json.address(cache: "some_other_cache_key") do
json.zip 11214
end
end
When used with arrays, PropsTemplate will use Rails.cache.read_multi
.
require 'props_template/core_ext'
opts = { cache: ->(i){ ['a', i] } }
json.array! [4,5], opts do |x|
json.top "hello" + x.to_s
end
#or on arrays with partials
opts = { cache: (->(d){ ['a', d.id] }), partial: ["blog_post", as: :blog_post] }
json.array! @options, opts do
end
Deferment
You can defer rendering of expensive nodes in your content tree using the
defer: :manual
option. Behind the scenes PropsTemplates will no-op the block
entirely and replace the value with a placeholder. A common use case would be
tabbed content that does not load until you click the tab.
When your client receives the payload, you may issue a second request to the same endpoint to fetch any missing nodes. See traversing nodes
There is also an defer: :auto
option that you can use with SuperglueJS. SuperglueJS
will use the metadata from json.deferred!
to issue a remote
dispatch to fetch
the missing node and immutably graft it at the appropriate keypath in your Redux
store.
Usage:
json.dashboard(defer: :manual) do
sleep 10
json.someFancyMetric 42
end
# or you can explicitly pass a placeholder
json.dashboard(defer: [:manual, placeholder: {}]) do
sleep 10
json.someFancyMetric 42
end
A auto option is available:
Note This is a SuperglueJS specific functionality.
json.dashboard(defer: :auto) do
sleep 10
json.someFancyMetric 42
end
Finally in your application.json.props
:
json.defers json.deferred!
Working with arrays
The default behavior for deferements is to use the index of the collection to identify an element.
Note If you are using this library with SuperglueJS, the :auto
options will
generate ?props_at=a.b.c.0.title
for json.deferred!
.
If you wish to use an attribute to identify the element. You must:
-
Use the
:key
option onjson.array!
. This key refers to an attribute on your collection item, and is used fordefer: :auto
to generate a keypath for SuperglueJS. If you are NOT using SuperglueJS, you do not need to do this. -
Implement
member_at
, on the collection. This will be called by PropsTemplate to when searching nodes
For example:
require 'props_template/core_ext'
data = [
{id: 1, name: 'foo'},
{id: 2, name: 'bar'}
]
json.posts
json.array! data, key: :some_id do |item|
# By using :key, props_template will append `json.some_id item.some_id`
# automatically
json.contact(defer: :auto) do
json.address '123 example drive'
end
end
end
If you are using SuperglueJS, SuperglueJS will, it will automatically kick off
remote(?props_at=posts.some_id=1.contact)
and remote(?props_at=posts.some_id=2.contact)
.
Traversing
PropsTemplate has the ability to walk the tree you build, skipping execution of untargeted nodes. This feature is useful for selectively updating your frontend state.
traversal_path = ['data', 'details', 'personal']
json.data(search: traversal_path) do
json.details do
json.employment do
...more stuff
end
json.personal do
json.name 'james'
json.zipCode 91210
end
end
end
json.footer do
...
end
PropsTemplate will walk depth first, walking only when it finds a matching key, then executes the associated block, and repeats until it the node is found. The above will output:
{
"data": {
"name": 'james',
"zipCode": 91210
},
"footer": {
...
}
}
Searching only works with blocks, and will NOT work with Scalars ("leaf" values). For example:
traversal_path = ['data', 'details', 'personal', 'name'] <- not found
json.data(search: traversal_path) do
json.details do
json.personal do
json.name 'james'
end
end
end
Nodes that do not exist
Nodes that are not found will remove the branch where search was enabled on.
traversal_path = ['data', 'details', 'does_not_exist']
json.data(search: traversal_path) do
json.details do
json.personal do
json.name 'james'
end
end
end
json.footer do
...
end
The above will render:
{
"footer": {
...
}
}
Layouts
A single layout is supported. To use, create an application.json.props
in
app/views/layouts
. Here's an example:
json.data do
# template runs here.
yield json
end
json.header do
json.greeting "Hello"
end
json.footer do
json.greeting "Hello"
end
json.flash flash.to_h
NOTE PropsTemplate inverts the usual Rails rendering flow. PropsTemplate
will render Layout first, then the template when yield json
is used.
Change key format
By default, keys are not formatted. This is intentional. By being explicity with your keys, it makes your views quicker and more easily searchable when working in Javascript land.
If you must change this behavior, override it in an initializer and cache the value:
# default behavior
Props::BaseWithExtensions.class_eval do
# json.firstValue "first"
# json.second_value "second"
#
# -> { "firstValue" => "first", "second_value" => "second" }
def key_format(key)
key.to_s
end
end
# camelCased behavior
Props::BaseWithExtensions.class_eval do
# json.firstValue "first"
# json.second_value "second"
#
# -> { "firstValue" => "first", "secondValue" => "second" }
def key_format(key)
@key_cache ||= {}
@key_cache[key] ||= key.to_s.camelize(:lower)
@key_cache[key]
end
def result!
result = super
@key_cache = {}
result
end
end
# snake_cased behavior
Props::BaseWithExtensions.class_eval do
# json.firstValue "first"
# json.second_value "second"
#
# -> { "first_value" => "first", "second_value" => "second" }
def key_format(key)
@key_cache ||= {}
@key_cache[key] ||= key.to_s.underscore
@key_cache[key]
end
def result!
result = super
@key_cache = {}
result
end
end
Escape mode
PropsTemplate runs OJ with mode: :rails
, which escapes HTML and XML characters
such as &
and <
.
Contributing
See the CONTRIBUTING document. Thank you, contributors!
Special Thanks
Thanks to turbostreamer, oj, and jbuilder for the inspiration.