presentability 0.5.0 → 0.6.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/GettingStarted.md +234 -0
- data/History.md +10 -0
- data/README.md +3 -1
- data/lib/presentability/presenter.rb +115 -3
- data/lib/presentability.rb +166 -5
- data/lib/roda/plugins/presenters.rb +57 -0
- data/spec/presentability_spec.rb +105 -11
- data/spec/roda/plugins/presenters_spec.rb +71 -0
- data/spec/spec_helper.rb +9 -0
- data.tar.gz.sig +0 -0
- metadata +32 -17
- metadata.gz.sig +0 -0
- data/Presentability.md +0 -105
- data/Presenter.md +0 -109
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a47d99629c4294cef8b308ac17e98f5be3967d9c2663caab2ab633c81d512d2e
|
4
|
+
data.tar.gz: 0c325f17f38543a9a7420a9e1ecf727140ecfdc449853ea434f7643343b710e3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e5a49d7d49f0b9f0bc780097620394ecd8d96d62722e746bd34bb9834ba080b767d01527f2417bfb8b8e85c8cda6522621c6b9e0184b0791361343c2fb7b6461
|
7
|
+
data.tar.gz: dbc38700dccecb3e48c18525a836a6b5f2115e6fae5c662e2df7f325e54cd68b82d1940178ab98be41a89fda98a97dc502a18c6038cbd39c2c2e86a5d45890d5
|
checksums.yaml.gz.sig
CHANGED
Binary file
|
data/GettingStarted.md
ADDED
@@ -0,0 +1,234 @@
|
|
1
|
+
# Presentability - Getting Started
|
2
|
+
|
3
|
+
To set up your own presentation layer, there are three steps:
|
4
|
+
|
5
|
+
- Set up a Module as a presenter collection.
|
6
|
+
- Declare one or more _presenters_ within the collection.
|
7
|
+
- Use the collection to present entities from a service or other limited interface.
|
8
|
+
|
9
|
+
For the purposes of this document, we'll pretend we're responsible for creating a JSON web service for Acme Widgets, Inc. We've declared all of our company's code inside the `Acme` namespace. We're using a generic Sinatra-like web framework that lets you declare endpoints like so:
|
10
|
+
|
11
|
+
```ruby
|
12
|
+
get '/status_check' do |parameters|
|
13
|
+
return { status: 'success' }.to_json
|
14
|
+
end
|
15
|
+
```
|
16
|
+
|
17
|
+
|
18
|
+
## Create a Presenter Collection
|
19
|
+
|
20
|
+
A _presenter collection_ is just a Module somewhere within your namespace that one can use to access the declared presenters. You designate it as a presenter collection simply by `extend`ing `Presentability`.
|
21
|
+
|
22
|
+
We'll declare ours under `Acme` and call it `Presenters`:
|
23
|
+
|
24
|
+
```ruby
|
25
|
+
require 'presentability'
|
26
|
+
|
27
|
+
require 'acme'
|
28
|
+
|
29
|
+
module Acme::Presenters
|
30
|
+
extend Presentability
|
31
|
+
|
32
|
+
# Presenters will be declared here
|
33
|
+
|
34
|
+
end # module Acme::Presenters
|
35
|
+
```
|
36
|
+
|
37
|
+
Since we haven't declared any presenters, the collection isn't really all that useful yet, so let's declare some.
|
38
|
+
|
39
|
+
|
40
|
+
## Declare Presenters
|
41
|
+
|
42
|
+
A _presenter_ is an object that is responsible for constructing a _representation_ of another object. There are a number of reasons to use a presenter:
|
43
|
+
|
44
|
+
- Security: avoid exposing sensitive data from your domain objects to the public, e.g., passwords, internal prices, numeric IDs, etc.
|
45
|
+
- Transform: normalize and flatten complex objects to some standard form, e.g., convert `Time` object timestamps to RFC 2822 form.
|
46
|
+
- Consistency: change your model layer independently of your service's entities, e.g., adding a new column to a model doesn't automatically expose it in the service layer.
|
47
|
+
|
48
|
+
The _representation_ is just a simple object that serves as an intermediate form for the transformed object until it is ultimately encoded. The default _representation_ is an empty `Hash`, but you can customize it to suit your needs.
|
49
|
+
|
50
|
+
To declare a presenter, we'll call the `presenter_for` method on the presenter collection module, and then call `expose` or `expose_collection` for each attribute that should be exposed.
|
51
|
+
|
52
|
+
The first argument to `presenter_for` is the type of object the presenter is for, which can be specified in a couple of different ways. The easiest is to just pass in the class itself. The domain class the Acme service is built around is the Widget, so let's declare a presenter for it:
|
53
|
+
|
54
|
+
```ruby
|
55
|
+
require 'acme/widget'
|
56
|
+
|
57
|
+
module Acme::Presenters
|
58
|
+
extend Presentability
|
59
|
+
|
60
|
+
presenter_for Acme::Widget do
|
61
|
+
expose :name
|
62
|
+
end
|
63
|
+
|
64
|
+
end # module Acme::Presenters
|
65
|
+
```
|
66
|
+
|
67
|
+
To present an object, call `.present` on the collection module with the object to be presented, and it will return a representation that is a `Hash` with a single `:name` key-value pair:
|
68
|
+
|
69
|
+
```ruby
|
70
|
+
widget = Acme::Widget.where( name: 'The Red One' )
|
71
|
+
Acme::Presenters.present( widget )
|
72
|
+
# => { name: "The Red One" }
|
73
|
+
```
|
74
|
+
|
75
|
+
If we want to add a `sku` field to all widgets served by our service, we just add another exposure:
|
76
|
+
|
77
|
+
```ruby
|
78
|
+
expose :sku
|
79
|
+
```
|
80
|
+
|
81
|
+
```ruby
|
82
|
+
widget = Acme::Widget.where( name: 'The Red One' )
|
83
|
+
Acme::Presenters.present( widget )
|
84
|
+
# => { name: "The Red One", sku: 'DGG-17044-0822' }
|
85
|
+
```
|
86
|
+
|
87
|
+
### Overriding Exposures
|
88
|
+
|
89
|
+
Sometime you want to alter the value that appears for a particular field. Say, for example, that the SKU that we exposed in our Widget presenter has an internal-only suffix in the form: `-xxxx` that we'd like to avoid exposing in a public-facing service. We can accomplish this by adding a block to it that alters the field from the model. Inside this block, the original object can be accessed via the `subject` method, so we can call the original `#sku` method and truncate it:
|
90
|
+
|
91
|
+
```ruby
|
92
|
+
expose :sku do
|
93
|
+
original = self.subject.sku
|
94
|
+
return original.sub(/-\d{4}/, '')
|
95
|
+
end
|
96
|
+
```
|
97
|
+
|
98
|
+
Now the last part of the SKU will be removed in the representation:
|
99
|
+
|
100
|
+
```ruby
|
101
|
+
widget = Acme::Widget.where( name: 'The Red One' )
|
102
|
+
Acme::Presenters.present( widget )
|
103
|
+
# => { name: "The Red One", sku: 'DGG-17044' }
|
104
|
+
```
|
105
|
+
|
106
|
+
### Exposure Options
|
107
|
+
|
108
|
+
You can also pass zero or more options as a keyword Hash when presenting:
|
109
|
+
|
110
|
+
```ruby
|
111
|
+
Acme::Presenters.present( widget, internal: true )
|
112
|
+
```
|
113
|
+
|
114
|
+
There are a few ways options can be used out of the box:
|
115
|
+
|
116
|
+
#### Exposure Aliases
|
117
|
+
|
118
|
+
Sometimes you want the field in the representation to have a different name than the method on the model object:
|
119
|
+
|
120
|
+
```ruby
|
121
|
+
presenter_for Acme::Company do
|
122
|
+
expose :id
|
123
|
+
expose :legal_entity, as: :name
|
124
|
+
expose :icon_url, as: :icon
|
125
|
+
end
|
126
|
+
```
|
127
|
+
|
128
|
+
In the representation, the `#legal_entity` method will be called on the `Company` being presented and the return value associated with the `:name` key, and the same for `#icon_url` and `:icon`:
|
129
|
+
|
130
|
+
```ruby
|
131
|
+
{ id: 4, name: "John's Small Grapes", icon: "grapes-100.png" }
|
132
|
+
```
|
133
|
+
|
134
|
+
#### Conditional Exposure
|
135
|
+
|
136
|
+
You can make an exposure conditional on an option being passed or not:
|
137
|
+
|
138
|
+
```ruby
|
139
|
+
# Don't include the price if presented with `public: true` option set
|
140
|
+
expose :price, unless: :public
|
141
|
+
|
142
|
+
# Only include the settings if presented with `detailed: true` option set
|
143
|
+
expose :settings, if: :detailed
|
144
|
+
```
|
145
|
+
|
146
|
+
#### Collection Exposure
|
147
|
+
|
148
|
+
A common use-case for conditional presentations is when you want an entity in a collection to be a less-detailed version. E.g.,
|
149
|
+
|
150
|
+
```ruby
|
151
|
+
presenter_for Acme::User do
|
152
|
+
expose :id
|
153
|
+
expose :username
|
154
|
+
expose :email
|
155
|
+
|
156
|
+
expose :settings, unless: :in_collection
|
157
|
+
end
|
158
|
+
```
|
159
|
+
|
160
|
+
You can pass `in_collection: true` when you're presenting, but you can also use the `present_collection` convenience method which sets it for you:
|
161
|
+
|
162
|
+
```ruby
|
163
|
+
users = Acme::User.where( :activated ).limit( 20 )
|
164
|
+
Acme::Presenters.present_collection( users )
|
165
|
+
# => [{ id: 1, username: 'fran', email: 'fran@example.com'}, ...]
|
166
|
+
```
|
167
|
+
|
168
|
+
#### Custom Options
|
169
|
+
|
170
|
+
You also have access to the presenter options (via the `#options` method) in a overridden exposure block. With this you can build your own presentation logic:
|
171
|
+
|
172
|
+
```ruby
|
173
|
+
presenter_for Acme::Widget do
|
174
|
+
expose :name
|
175
|
+
expose :sku
|
176
|
+
|
177
|
+
expose :scancode do
|
178
|
+
self.subject.make_scancode( self.options[:scantype] )
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
# In your service:
|
183
|
+
widget = Acme::Widget[5]
|
184
|
+
Acme::Presenters.present( widget, scantype: :qr )
|
185
|
+
# { name: "Duck Quackers", sku: 'HBG-121-0424', scancode: '<qrcode data>'}
|
186
|
+
```
|
187
|
+
|
188
|
+
|
189
|
+
## Declare Serializers
|
190
|
+
|
191
|
+
Oftentimes your model objects include values which are themselves not inherently serializable to your representation format. To help with this, you can also declare a "serializer" for one or more classes in your collection using the `.serializer_for` method:
|
192
|
+
|
193
|
+
```ruby
|
194
|
+
require 'time' # for Time#rfc2822
|
195
|
+
|
196
|
+
module Acme::Presenters
|
197
|
+
extend Presentability
|
198
|
+
|
199
|
+
serializer_for :IPAddr, :to_s
|
200
|
+
serializer_for Time, :rfc2822
|
201
|
+
serializer_for Set, :to_a
|
202
|
+
|
203
|
+
end # module Acme::Presenters
|
204
|
+
```
|
205
|
+
|
206
|
+
Now when one of your models includes any of the given types, the corresponding method will be called on it and the result used as the value instead.
|
207
|
+
|
208
|
+
## Use the Roda Plugin
|
209
|
+
|
210
|
+
If you're using the excellent [Roda](https://roda.jeremyevans.net/) web framework, `Presentability` includes a plugin for using it in your Roda application. To enable it, in your app just `require` your collection and enable the plugin. That will enable you to use `#present` and `#present_collection` in your routes:
|
211
|
+
|
212
|
+
```ruby
|
213
|
+
require 'roda'
|
214
|
+
require 'acme/presenters'
|
215
|
+
|
216
|
+
class Acme::WebService < Roda
|
217
|
+
|
218
|
+
plugin :presenters, collection: Acme::Presenters
|
219
|
+
plugin :json
|
220
|
+
|
221
|
+
route do |r|
|
222
|
+
r.on "users" do
|
223
|
+
r.is do
|
224
|
+
# GET /users
|
225
|
+
r.get do
|
226
|
+
users = Acme::User.where( :activated )
|
227
|
+
present_collection( users.all )
|
228
|
+
end
|
229
|
+
end
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end # class Acme::WebService
|
233
|
+
```
|
234
|
+
|
data/History.md
CHANGED
@@ -1,6 +1,16 @@
|
|
1
1
|
# Release History for presentability
|
2
2
|
|
3
3
|
---
|
4
|
+
|
5
|
+
## v0.6.0 [2024-04-29] Michael Granger <ged@faeriemud.org>
|
6
|
+
|
7
|
+
Improvements:
|
8
|
+
|
9
|
+
- Added custom serialization, and better collection handling
|
10
|
+
- Added a Roda plugin
|
11
|
+
- Improved documentation
|
12
|
+
|
13
|
+
|
4
14
|
## v0.5.0 [2023-11-06] Michael Granger <ged@faeriemud.org>
|
5
15
|
|
6
16
|
Improvements:
|
data/README.md
CHANGED
@@ -56,6 +56,8 @@ your data objects that return a limited representation of their subjects.
|
|
56
56
|
More details can be found in the docs for the Presentability module, and in
|
57
57
|
Presentability::Presenter.
|
58
58
|
|
59
|
+
See GettingStarted to get started with your own presenters.
|
60
|
+
|
59
61
|
|
60
62
|
## Prerequisites
|
61
63
|
|
@@ -88,7 +90,7 @@ This will install dependencies, and do any other necessary setup for development
|
|
88
90
|
|
89
91
|
## License
|
90
92
|
|
91
|
-
Copyright (c) 2022, Michael Granger
|
93
|
+
Copyright (c) 2022-2024, Michael Granger
|
92
94
|
All rights reserved.
|
93
95
|
|
94
96
|
Redistribution and use in source and binary forms, with or without
|
@@ -4,7 +4,119 @@ require 'loggability'
|
|
4
4
|
|
5
5
|
require 'presentability' unless defined?( Presentability )
|
6
6
|
|
7
|
-
#
|
7
|
+
#
|
8
|
+
# A presenter (facade) base class.
|
9
|
+
#
|
10
|
+
#
|
11
|
+
# ### Declaring Presenters
|
12
|
+
#
|
13
|
+
# When you declare a presenter in a Presentability collection, the result is a
|
14
|
+
# subclass of Presentability::Presenter. The main way of defining a Presenter's
|
15
|
+
# functionality is via the ::expose method, which marks an attribute of the underlying
|
16
|
+
# entity object (the "subject") for exposure.
|
17
|
+
#
|
18
|
+
# class MyPresenter < Presentability::Presenter
|
19
|
+
# expose :name
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
# # Assuming `entity_object' has a "name" attribute...
|
23
|
+
# presenter = MyPresenter.new( entity_object )
|
24
|
+
# presenter.apply
|
25
|
+
# # => { :name => "entity name" }
|
26
|
+
#
|
27
|
+
#
|
28
|
+
# ### Presenter Collections
|
29
|
+
#
|
30
|
+
# Setting up classes manually like this is one option, but Presentability also lets you
|
31
|
+
# set them up as a collection, which is what further examples will assume for brevity:
|
32
|
+
#
|
33
|
+
# module MyPresenters
|
34
|
+
# extend Presentability
|
35
|
+
#
|
36
|
+
# presenter_for( EntityObject ) do
|
37
|
+
# expose :name
|
38
|
+
# end
|
39
|
+
#
|
40
|
+
# end
|
41
|
+
#
|
42
|
+
#
|
43
|
+
# ### Complex Exposures
|
44
|
+
#
|
45
|
+
# Sometimes you want to do more than just use the presented entity's values as-is.
|
46
|
+
# There are a number of ways to do this.
|
47
|
+
#
|
48
|
+
# The first of these is to provide a block when exposing an attribute. The subject
|
49
|
+
# of the presenter is available to the block via the `subject` method:
|
50
|
+
#
|
51
|
+
# require 'time'
|
52
|
+
#
|
53
|
+
# presenter_for( LogEvent ) do
|
54
|
+
# # Turn Time objects into RFC2822-formatted time strings
|
55
|
+
# expose :timestamp do
|
56
|
+
# self.subject.timestamp.rfc2822
|
57
|
+
# end
|
58
|
+
#
|
59
|
+
# end
|
60
|
+
#
|
61
|
+
# You can also declare the exposure using a regular method with the same name:
|
62
|
+
#
|
63
|
+
# require 'time'
|
64
|
+
#
|
65
|
+
# presenter_for( LogEvent ) do
|
66
|
+
# # Turn Time objects into RFC2822-formatted time strings
|
67
|
+
# expose :timestamp
|
68
|
+
#
|
69
|
+
# def timestamp
|
70
|
+
# return self.subject.timestamp.rfc2822
|
71
|
+
# end
|
72
|
+
#
|
73
|
+
# end
|
74
|
+
#
|
75
|
+
# This can be used to add presence checks:
|
76
|
+
#
|
77
|
+
# require 'time'
|
78
|
+
#
|
79
|
+
# presenter_for( LogEvent ) do
|
80
|
+
# # Require that presented entities have an `id` attribute
|
81
|
+
# expose :id do
|
82
|
+
# id = self.subject.id or raise "no `id' for %p" % [ self.subject ]
|
83
|
+
# raise "`id' for %p is blank!" % [ self.subject ] if id.blank?
|
84
|
+
#
|
85
|
+
# return id
|
86
|
+
# end
|
87
|
+
# end
|
88
|
+
#
|
89
|
+
# or conditional exposures:
|
90
|
+
#
|
91
|
+
# presenter_for( Acme::Product ) do
|
92
|
+
#
|
93
|
+
# # Truncate the long description if presented as part of a collection
|
94
|
+
# expose :detailed_description do
|
95
|
+
# desc = self.subject.detailed_description
|
96
|
+
# if self.options[:in_collection]
|
97
|
+
# return desc[0..15] + '...'
|
98
|
+
# else
|
99
|
+
# return desc
|
100
|
+
# end
|
101
|
+
# end
|
102
|
+
#
|
103
|
+
# end
|
104
|
+
#
|
105
|
+
#
|
106
|
+
# ### Exposure Aliases
|
107
|
+
#
|
108
|
+
# If you want to expose a field but use a different name in the resulting data
|
109
|
+
# structure, you can use the `:as` option in the exposure declaration:
|
110
|
+
#
|
111
|
+
# presenter_for( LogEvent ) do
|
112
|
+
# expose :timestamp, as: :created_at
|
113
|
+
# end
|
114
|
+
#
|
115
|
+
# presenter = MyPresenter.new( log_event )
|
116
|
+
# presenter.apply
|
117
|
+
# # => { :created_at => '2023-02-01 12:34:02.155365 -0800' }
|
118
|
+
#
|
119
|
+
#
|
8
120
|
class Presentability::Presenter
|
9
121
|
extend Loggability
|
10
122
|
|
@@ -120,9 +232,9 @@ class Presentability::Presenter
|
|
120
232
|
|
121
233
|
self.class.exposures.each do |name, exposure_options|
|
122
234
|
next if self.skip_exposure?( name )
|
235
|
+
self.log.debug "Presenting %p" % [ name ]
|
123
236
|
value = self.method( name ).call
|
124
|
-
value = presenters.present( value, **exposure_options )
|
125
|
-
value.is_a?( result.class )
|
237
|
+
value = presenters.present( value, **exposure_options )
|
126
238
|
key = exposure_options.key?( :as ) ? exposure_options[:as] : name
|
127
239
|
result[ key.to_sym ] = value
|
128
240
|
end
|
data/lib/presentability.rb
CHANGED
@@ -3,14 +3,118 @@
|
|
3
3
|
require 'loggability'
|
4
4
|
|
5
5
|
|
6
|
-
#
|
6
|
+
#
|
7
|
+
# Facade-based presenter toolkit with minimal assumptions.
|
8
|
+
#
|
9
|
+
# ## Basic Usage
|
10
|
+
#
|
11
|
+
# Basic usage of Presentability requires two steps: declaring presenters and
|
12
|
+
# then using them.
|
13
|
+
#
|
14
|
+
# ### Declaring Presenters
|
15
|
+
#
|
16
|
+
# Presenters are just regular Ruby classes with some convenience methods for
|
17
|
+
# declaring exposures, but in a lot of cases you'll want to declare them all in
|
18
|
+
# one place. Presentability offers a mixin that implements a simple DSL for
|
19
|
+
# declaring presenters and their associations to entity classes, intended to be
|
20
|
+
# used in a container module:
|
21
|
+
#
|
22
|
+
# require 'presentability'
|
23
|
+
#
|
24
|
+
# module Acme::Presenters
|
25
|
+
# extend Presentability
|
26
|
+
#
|
27
|
+
# presenter_for( Acme::Widget ) do
|
28
|
+
# expose :sku
|
29
|
+
# expose :name
|
30
|
+
# expose :unit_price
|
31
|
+
# end
|
32
|
+
#
|
33
|
+
# end
|
34
|
+
#
|
35
|
+
# The block of `presenter_for` is evaluated in the context of a new Presenter
|
36
|
+
# class, so refer to that documentation for what's possible there.
|
37
|
+
#
|
38
|
+
# Sometimes you can't (or don't want to) have to load the entity class to
|
39
|
+
# declare a presenter for it, so you can also declare it using the class's name:
|
40
|
+
#
|
41
|
+
# presenter_for( 'Acme::Widget' ) do
|
42
|
+
# expose :sku
|
43
|
+
# expose :name
|
44
|
+
# expose :unit_price
|
45
|
+
# end
|
46
|
+
#
|
47
|
+
#
|
48
|
+
# ### Using Presenters
|
49
|
+
#
|
50
|
+
# You use presenters by instantiating them with the object they are a facade for
|
51
|
+
# (the "subject"), and then applying it:
|
52
|
+
#
|
53
|
+
# acme_widget = Acme::Widget.new(
|
54
|
+
# sku: "FF-2237H455",
|
55
|
+
# name: "Throbbing Frobnulator",
|
56
|
+
# unit_price: 299,
|
57
|
+
# inventory_count: 301,
|
58
|
+
# wholesale_cost: 39
|
59
|
+
# )
|
60
|
+
# presentation = Acme::Presenters.present( acme_widget )
|
61
|
+
# # => { :sku => "FF-2237H455", :name => "Throbbing Frobnulator", :unit_price => 299 }
|
62
|
+
#
|
63
|
+
# If you want to present a collection of objects as a collection, you can apply
|
64
|
+
# presenters to the collection instead:
|
65
|
+
#
|
66
|
+
# widgets_in_stock = Acme::Widget.where { inventory_count > 0 }
|
67
|
+
# collection_presentation = Acme::Presenters.present_collection( widgets_in_stock )
|
68
|
+
# # => [ {:sku => "FF-2237H455", [...]}, {:sku => "FF-2237H460", [...]}, [...] ]
|
69
|
+
#
|
70
|
+
# The collection can be anything that is `Enumerable`.
|
71
|
+
#
|
72
|
+
#
|
73
|
+
# ### Presentation Options
|
74
|
+
#
|
75
|
+
# Sometimes you want a bit more flexibility in what you present, allowing a single
|
76
|
+
# uniform presenter to be used in multiple use cases. To facilitate this, you can pass
|
77
|
+
# an options keyword hash to `#present`:
|
78
|
+
#
|
79
|
+
# presenter_for( 'Acme::Widget' ) do
|
80
|
+
# expose :sku
|
81
|
+
# expose :name
|
82
|
+
# expose :unit_price
|
83
|
+
#
|
84
|
+
# # Only expose the wholesale cost if presented via an internal API
|
85
|
+
# expose :wholesale_cost, if: :internal_api
|
86
|
+
# end
|
87
|
+
#
|
88
|
+
# acme_widget = Acme::Widget.new(
|
89
|
+
# sku: "FF-2237H455",
|
90
|
+
# name: "Throbbing Frobnulator",
|
91
|
+
# unit_price: 299,
|
92
|
+
# inventory_count: 301,
|
93
|
+
# wholesale_cost: 39
|
94
|
+
# )
|
95
|
+
#
|
96
|
+
# # External API remains unchanged:
|
97
|
+
# presentation = Acme::Presenters.present( acme_widget )
|
98
|
+
# # => { :sku => "FF-2237H455", :name => "Throbbing Frobnulator", :unit_price => 299 }
|
99
|
+
#
|
100
|
+
# # But when run from an internal service:
|
101
|
+
# internal_presentation = Acme::Presenters.present( acme_widget, internal_api: true )
|
102
|
+
# # => { :sku => "FF-2237H455", :name => "Throbbing Frobnulator", :unit_price => 299,
|
103
|
+
# # :wholesale_cost => 39 }
|
104
|
+
#
|
105
|
+
# There are some options that are set for you:
|
106
|
+
#
|
107
|
+
# <dl>
|
108
|
+
# <td><code>:in_collection</code></td>
|
109
|
+
# <dd>Set if the current object is being presented as part of a collection.</dd>
|
110
|
+
# </dl>
|
111
|
+
#
|
7
112
|
module Presentability
|
8
113
|
extend Loggability
|
9
114
|
|
10
115
|
|
11
116
|
# Package version
|
12
|
-
VERSION = '0.
|
13
|
-
|
117
|
+
VERSION = '0.6.0'
|
14
118
|
|
15
119
|
# Automatically load subordinate components
|
16
120
|
autoload :Presenter, 'presentability/presenter'
|
@@ -20,15 +124,29 @@ module Presentability
|
|
20
124
|
log_as :presentability
|
21
125
|
|
22
126
|
|
127
|
+
#
|
128
|
+
# Hooks
|
129
|
+
#
|
130
|
+
|
23
131
|
### Extension hook -- decorate the including +mod+.
|
24
132
|
def self::extended( mod )
|
25
133
|
super
|
26
134
|
mod.singleton_class.attr_accessor :presenters
|
135
|
+
mod.singleton_class.attr_accessor :serializers
|
136
|
+
|
27
137
|
mod.presenters = {}
|
138
|
+
mod.serializers = {
|
139
|
+
Array => mod.method( :serialize_array ),
|
140
|
+
Hash => mod.method( :serialize_hash ),
|
141
|
+
}
|
28
142
|
end
|
29
143
|
|
30
144
|
|
31
145
|
|
146
|
+
#
|
147
|
+
# DSL Methods
|
148
|
+
#
|
149
|
+
|
32
150
|
### Set up a presentation for the given +entity_class+.
|
33
151
|
def presenter_for( entity_class, &block )
|
34
152
|
presenter_class = Class.new( Presentability::Presenter )
|
@@ -38,16 +156,28 @@ module Presentability
|
|
38
156
|
end
|
39
157
|
|
40
158
|
|
159
|
+
### Set up a rule for how to serialize objects of the given +type+ if there is
|
160
|
+
### no presenter declared for it.
|
161
|
+
def serializer_for( type, method )
|
162
|
+
self.serializers[ type ] = method
|
163
|
+
end
|
164
|
+
|
165
|
+
|
166
|
+
#
|
167
|
+
# Presentation Methods
|
168
|
+
#
|
169
|
+
|
41
170
|
### Return a representation of the +object+ by applying a declared presentation.
|
42
171
|
def present( object, **options )
|
43
172
|
representation = self.present_by_class( object, **options ) ||
|
44
|
-
self.present_by_classname( object, **options )
|
173
|
+
self.present_by_classname( object, **options ) ||
|
174
|
+
self.serialize( object, **options )
|
45
175
|
|
46
176
|
unless representation
|
47
177
|
if object.instance_variables.empty?
|
48
178
|
return object
|
49
179
|
else
|
50
|
-
raise NoMethodError, "no presenter found for %p" % [ object ]
|
180
|
+
raise NoMethodError, "no presenter found for %p" % [ object ], caller( 1 )
|
51
181
|
end
|
52
182
|
end
|
53
183
|
|
@@ -63,6 +193,37 @@ module Presentability
|
|
63
193
|
end
|
64
194
|
|
65
195
|
|
196
|
+
### Serialize the specified +object+ if a serializer has been declared for it
|
197
|
+
### and return the scalar result.
|
198
|
+
def serialize( object, ** )
|
199
|
+
serializer = self.serializers[ object.class ] ||
|
200
|
+
self.serializers[ object.class.name ] or
|
201
|
+
return nil
|
202
|
+
serializer_proc = serializer.to_proc
|
203
|
+
|
204
|
+
return serializer_proc.call( object )
|
205
|
+
end
|
206
|
+
|
207
|
+
|
208
|
+
### Default serializer for an Array; returns a new array of presented objects.
|
209
|
+
def serialize_array( object )
|
210
|
+
return object.map do |member|
|
211
|
+
self.present( member, in_collection: true )
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
|
216
|
+
### Default serializer for a Hash; returns a new Hash of presented keys and values.
|
217
|
+
def serialize_hash( object )
|
218
|
+
return object.each_with_object( {} ) do |(key, val), newhash|
|
219
|
+
p_key = self.present( key, in_collection: true )
|
220
|
+
p_val = self.present( val, in_collection: true )
|
221
|
+
|
222
|
+
newhash[ p_key ] = p_val
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
|
66
227
|
#########
|
67
228
|
protected
|
68
229
|
#########
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# -*- ruby -*-
|
2
|
+
|
3
|
+
require 'presentability'
|
4
|
+
|
5
|
+
require 'roda/roda_plugins' unless defined?( Roda::RodaPlugins )
|
6
|
+
|
7
|
+
|
8
|
+
module Roda::RodaPlugins::Presenters
|
9
|
+
|
10
|
+
# Default options
|
11
|
+
OPTS = {}.freeze
|
12
|
+
|
13
|
+
|
14
|
+
### Add presenter variables to the given +app+.
|
15
|
+
def self::configure( app, opts=OPTS, &block )
|
16
|
+
collection = opts[:collection] || Module.new
|
17
|
+
app.singleton_class.attr_accessor :presenter_collection
|
18
|
+
app.presenter_collection = collection
|
19
|
+
end
|
20
|
+
|
21
|
+
|
22
|
+
module ClassMethods
|
23
|
+
|
24
|
+
### Inheritance hook -- give +subclass+es their own presenters ivar.
|
25
|
+
def inherited( subclass )
|
26
|
+
super
|
27
|
+
subclass.presenter_collection = self.presenter_collection.clone
|
28
|
+
end
|
29
|
+
|
30
|
+
|
31
|
+
end # module ClassMethods
|
32
|
+
|
33
|
+
|
34
|
+
module InstanceMethods
|
35
|
+
|
36
|
+
### Find the presenter for the given +object+ and apply it with the given
|
37
|
+
### +options+. Raises an exception if no presenter can be found.
|
38
|
+
def present( object, **options )
|
39
|
+
mod = self.class.presenter_collection
|
40
|
+
return mod.present( object, **options )
|
41
|
+
end
|
42
|
+
|
43
|
+
|
44
|
+
### Find the presenter for the given +object+ and apply it with the given
|
45
|
+
### +options+. Raises an exception if no presenter can be found.
|
46
|
+
def present_collection( object, **options )
|
47
|
+
mod = self.class.presenter_collection
|
48
|
+
return mod.present_collection( object, **options )
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
52
|
+
|
53
|
+
|
54
|
+
Roda::RodaPlugins.register_plugin( :presenters, self )
|
55
|
+
|
56
|
+
end # module Roda::RodaPlugins::Presenters
|
57
|
+
|
data/spec/presentability_spec.rb
CHANGED
@@ -11,9 +11,7 @@ RSpec.describe Presentability do
|
|
11
11
|
|
12
12
|
let( :entity_class ) do
|
13
13
|
Class.new do
|
14
|
-
|
15
|
-
return 'Acme::Entity'
|
16
|
-
end
|
14
|
+
set_temporary_name 'Acme::Entity (Testing Class)'
|
17
15
|
|
18
16
|
def initialize( foo: 1, bar: 'two', baz: :three )
|
19
17
|
@foo = foo
|
@@ -27,9 +25,7 @@ RSpec.describe Presentability do
|
|
27
25
|
|
28
26
|
let( :other_entity_class ) do
|
29
27
|
Class.new do
|
30
|
-
|
31
|
-
return 'Acme::User'
|
32
|
-
end
|
28
|
+
set_temporary_name 'Acme::User (Testing Class)'
|
33
29
|
|
34
30
|
def initialize( firstname, lastname, email, password )
|
35
31
|
@firstname = firstname
|
@@ -45,9 +41,7 @@ RSpec.describe Presentability do
|
|
45
41
|
|
46
42
|
let( :complex_entity_class ) do
|
47
43
|
Class.new do
|
48
|
-
|
49
|
-
return 'Acme::Pair'
|
50
|
-
end
|
44
|
+
set_temporary_name 'Acme::Pair (Testing Class)'
|
51
45
|
|
52
46
|
def initialize( user:, entity:, overridden: false, locked: true )
|
53
47
|
@user = user
|
@@ -93,7 +87,7 @@ RSpec.describe Presentability do
|
|
93
87
|
|
94
88
|
|
95
89
|
it "can define a presenter for a class name" do
|
96
|
-
extended_module.presenter_for(
|
90
|
+
extended_module.presenter_for( entity_class.name ) do
|
97
91
|
expose :foo
|
98
92
|
expose :bar
|
99
93
|
end
|
@@ -218,10 +212,110 @@ RSpec.describe Presentability do
|
|
218
212
|
end
|
219
213
|
|
220
214
|
|
215
|
+
describe 'serialization' do
|
216
|
+
|
217
|
+
it "presents each element for Arrays by default" do
|
218
|
+
extended_module.presenter_for( entity_class ) do
|
219
|
+
expose :foo
|
220
|
+
end
|
221
|
+
|
222
|
+
array = 5.times.map { entity_class.new }
|
223
|
+
|
224
|
+
result = extended_module.present( array )
|
225
|
+
|
226
|
+
expect( result ).to eq( [{foo: 1}] * 5 )
|
227
|
+
end
|
228
|
+
|
229
|
+
|
230
|
+
it "presents each value for Hashes by default" do
|
231
|
+
extended_module.presenter_for( entity_class ) do
|
232
|
+
expose :foo
|
233
|
+
end
|
234
|
+
|
235
|
+
hash = { user1: entity_instance(), user2: entity_instance() }
|
236
|
+
|
237
|
+
result = extended_module.present( hash )
|
238
|
+
|
239
|
+
expect( result ).to eq({
|
240
|
+
user1: {foo: 1}, user2: {foo: 1}
|
241
|
+
})
|
242
|
+
end
|
243
|
+
|
244
|
+
|
245
|
+
it "presents each key for Hashes by default too" do
|
246
|
+
extended_module.presenter_for( entity_class ) do
|
247
|
+
expose :id do
|
248
|
+
self.subject.object_id
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
key1 = entity_instance()
|
253
|
+
key2 = entity_instance()
|
254
|
+
hash = { key1 => 'user1', key2 => 'user2' }
|
255
|
+
|
256
|
+
result = extended_module.present( hash )
|
257
|
+
|
258
|
+
expect( result ).to eq({
|
259
|
+
{id: key1.object_id} => 'user1',
|
260
|
+
{id: key2.object_id} => 'user2'
|
261
|
+
})
|
262
|
+
end
|
263
|
+
|
264
|
+
|
265
|
+
it "automatically sets :in_collection for sub-Arrays" do
|
266
|
+
extended_module.presenter_for( entity_class ) do
|
267
|
+
expose :foo
|
268
|
+
expose :bar, unless: :in_collection
|
269
|
+
expose :baz
|
270
|
+
end
|
271
|
+
|
272
|
+
results = extended_module.present( [entity_instance] )
|
273
|
+
|
274
|
+
expect( results.first ).to include( :foo, :baz )
|
275
|
+
expect( results.first ).not_to include( :bar )
|
276
|
+
end
|
277
|
+
|
278
|
+
|
279
|
+
it "automatically sets :in_collection for sub-Hashes" do
|
280
|
+
extended_module.presenter_for( entity_class ) do
|
281
|
+
expose :foo
|
282
|
+
expose :bar, unless: :in_collection
|
283
|
+
expose :baz
|
284
|
+
end
|
285
|
+
|
286
|
+
results = extended_module.present( {result: entity_instance} )
|
287
|
+
|
288
|
+
expect( results[:result] ).to include( :foo, :baz )
|
289
|
+
expect( results[:result] ).not_to include( :bar )
|
290
|
+
end
|
291
|
+
|
292
|
+
|
293
|
+
it "can be defined by class for objects that have a simple presentation" do
|
294
|
+
extended_module.serializer_for( IPAddr, :to_s )
|
295
|
+
|
296
|
+
object = IPAddr.new( '127.0.0.1/24' )
|
297
|
+
|
298
|
+
expect( extended_module.present(object) ).to eq( '127.0.0.0' )
|
299
|
+
end
|
300
|
+
|
301
|
+
|
302
|
+
it "can be defined by class name for objects that have a simple presentation" do
|
303
|
+
extended_module.serializer_for( 'IPAddr', :to_s )
|
304
|
+
|
305
|
+
object = IPAddr.new( '127.0.0.1/24' )
|
306
|
+
|
307
|
+
expect( extended_module.present(object) ).to eq( '127.0.0.0' )
|
308
|
+
end
|
309
|
+
|
310
|
+
end
|
311
|
+
|
312
|
+
|
221
313
|
it "errors usefully if asked to present an object it knows nothing about" do
|
222
314
|
expect {
|
223
315
|
extended_module.present( entity_instance )
|
224
|
-
}.to raise_error( NoMethodError, /no presenter found/i )
|
316
|
+
}.to raise_error( NoMethodError, /no presenter found/i ) do |err|
|
317
|
+
expect( err.backtrace.first ).to match( /#{Regexp.escape(__FILE__)}/ )
|
318
|
+
end
|
225
319
|
end
|
226
320
|
|
227
321
|
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# -*- ruby -*-
|
2
|
+
|
3
|
+
require_relative '../../spec_helper'
|
4
|
+
|
5
|
+
require 'roda'
|
6
|
+
require 'roda/plugins/presenters'
|
7
|
+
|
8
|
+
|
9
|
+
RSpec.describe( Roda::RodaPlugins::Presenters ) do
|
10
|
+
|
11
|
+
let( :app ) { Class.new(Roda) }
|
12
|
+
|
13
|
+
let( :entity_class ) do
|
14
|
+
Class.new do
|
15
|
+
def self::name
|
16
|
+
return 'Acme::Entity'
|
17
|
+
end
|
18
|
+
|
19
|
+
def initialize( foo: 1, bar: 'two', baz: :three )
|
20
|
+
@foo = foo
|
21
|
+
@bar = bar
|
22
|
+
@baz = baz
|
23
|
+
end
|
24
|
+
|
25
|
+
attr_accessor :foo, :bar, :baz
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
|
30
|
+
it "adds an anonymous presenter collection to including apps" do
|
31
|
+
app.plugin( described_class )
|
32
|
+
|
33
|
+
expect( app.presenter_collection ).to be_a( Module )
|
34
|
+
end
|
35
|
+
|
36
|
+
|
37
|
+
it "clones the presenter collection for subclasses" do
|
38
|
+
app.plugin( described_class )
|
39
|
+
subapp = Class.new( app )
|
40
|
+
|
41
|
+
expect( subapp.presenter_collection ).to be_a( Module )
|
42
|
+
expect( app.presenter_collection ).to_not be( subapp.presenter_collection )
|
43
|
+
end
|
44
|
+
|
45
|
+
|
46
|
+
it "allows an existing presenter collection module to be added to including apps" do
|
47
|
+
collection = Module.new
|
48
|
+
app.plugin( described_class, collection: collection )
|
49
|
+
|
50
|
+
expect( app.presenter_collection ).to be( collection )
|
51
|
+
end
|
52
|
+
|
53
|
+
|
54
|
+
it "allows the use of presenters in application routes" do
|
55
|
+
collection = Module.new
|
56
|
+
collection.extend( Presentability )
|
57
|
+
collection.presenter_for( entity_class ) do
|
58
|
+
expose :foo
|
59
|
+
expose :bar
|
60
|
+
end
|
61
|
+
app.plugin( described_class, collection: collection )
|
62
|
+
|
63
|
+
app_instance = app.new( {} )
|
64
|
+
|
65
|
+
result = app_instance.present( entity_class.new )
|
66
|
+
expect( result ).to be_a( Hash ).and( include(:foo, :bar) )
|
67
|
+
expect( result ).to_not include( :baz )
|
68
|
+
end
|
69
|
+
|
70
|
+
end
|
71
|
+
|
data/spec/spec_helper.rb
CHANGED
@@ -18,6 +18,15 @@ I18n.reload!
|
|
18
18
|
|
19
19
|
require 'loggability/spechelpers'
|
20
20
|
|
21
|
+
if !Module.respond_to?( :set_temporary_name )
|
22
|
+
|
23
|
+
Module.class_eval {
|
24
|
+
def set_temporary_name( tempname )
|
25
|
+
define_singleton_method( :name ) { tempname }
|
26
|
+
end
|
27
|
+
}
|
28
|
+
|
29
|
+
end
|
21
30
|
|
22
31
|
### Mock with RSpec
|
23
32
|
RSpec.configure do |config|
|
data.tar.gz.sig
CHANGED
Binary file
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: presentability
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.6.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Michael Granger
|
@@ -10,9 +10,9 @@ bindir: bin
|
|
10
10
|
cert_chain:
|
11
11
|
- |
|
12
12
|
-----BEGIN CERTIFICATE-----
|
13
|
-
MIID+
|
14
|
-
|
15
|
-
|
13
|
+
MIID+DCCAmCgAwIBAgIBBjANBgkqhkiG9w0BAQsFADAiMSAwHgYDVQQDDBdnZWQv
|
14
|
+
REM9RmFlcmllTVVEL0RDPW9yZzAeFw0yNDAxMjkyMzI5MjJaFw0yNTAxMjgyMzI5
|
15
|
+
MjJaMCIxIDAeBgNVBAMMF2dlZC9EQz1GYWVyaWVNVUQvREM9b3JnMIIBojANBgkq
|
16
16
|
hkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAvyVhkRzvlEs0fe7145BYLfN6njX9ih5H
|
17
17
|
L60U0p0euIurpv84op9CNKF9tx+1WKwyQvQP7qFGuZxkSUuWcP/sFhDXL1lWUuIl
|
18
18
|
M4uHbGCRmOshDrF4dgnBeOvkHr1fIhPlJm5FO+Vew8tSQmlDsosxLUx+VB7DrVFO
|
@@ -23,17 +23,17 @@ cert_chain:
|
|
23
23
|
ozilJg4aar2okb/RA6VS87o+d7g6LpDDMMQjH4G9OPnJENLdhu8KnPw/ivSVvQw7
|
24
24
|
N2I4L/ZOIe2DIVuYH7aLHfjZDQv/mNgpAgMBAAGjOTA3MAkGA1UdEwQCMAAwCwYD
|
25
25
|
VR0PBAQDAgSwMB0GA1UdDgQWBBRyjf55EbrHagiRLqt5YAd3yb8k4DANBgkqhkiG
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
26
|
+
9w0BAQsFAAOCAYEATLX50LXGjumlF+AtWyDLRS5kKavyMRiyx2LbLBUYgJFBka2u
|
27
|
+
mQYapo5k4+oFgsChEGvAdukTjYJSj9GxLvgDT9mZTTtudOg1WSyUTuIuzlVVgps5
|
28
|
+
4XJ6WBo8IuEn4IBJ2s5FTkvEx8GKpOIa6nwEOrk2Qujx1Tk/ChQxR0sMwCbur/c1
|
29
|
+
y6rI18potiGD3ZNf1tFTeyQ9q1wQWn9sn2It047X5jypuJcYrQ95W8Tq6eI4W5CG
|
30
|
+
NkW6ib/vmrgzNmNsZ0IALjOoeQK9lxQ/a9WpNokaZinmtvysGCVonH67BUJI7Udt
|
31
|
+
usImdLjDpkOVaJSrUsHgnI8iq2F5qYKj2K3No+fTHTPNF4c9KtmvBb0uFxI4JSoY
|
32
|
+
F/9VtnYr7sx/akZGuCfcG7WwTnx1iasrQIwjwDG9YRbPDNnffTN50agKxgxmP6JB
|
33
|
+
gHMt4zxJlML5nLwM/RX6nhR8LXJrm5eTdXMw9JG8ZbBnTHdipBOnYvUXMgAA904m
|
34
|
+
nO/om6m3bLHIgJaE
|
35
35
|
-----END CERTIFICATE-----
|
36
|
-
date:
|
36
|
+
date: 2024-04-29 00:00:00.000000000 Z
|
37
37
|
dependencies:
|
38
38
|
- !ruby/object:Gem::Dependency
|
39
39
|
name: loggability
|
@@ -63,6 +63,20 @@ dependencies:
|
|
63
63
|
- - "~>"
|
64
64
|
- !ruby/object:Gem::Version
|
65
65
|
version: '3.0'
|
66
|
+
- !ruby/object:Gem::Dependency
|
67
|
+
name: roda
|
68
|
+
requirement: !ruby/object:Gem::Requirement
|
69
|
+
requirements:
|
70
|
+
- - "~>"
|
71
|
+
- !ruby/object:Gem::Version
|
72
|
+
version: '3.79'
|
73
|
+
type: :development
|
74
|
+
prerelease: false
|
75
|
+
version_requirements: !ruby/object:Gem::Requirement
|
76
|
+
requirements:
|
77
|
+
- - "~>"
|
78
|
+
- !ruby/object:Gem::Version
|
79
|
+
version: '3.79'
|
66
80
|
- !ruby/object:Gem::Dependency
|
67
81
|
name: rake-deveiate
|
68
82
|
requirement: !ruby/object:Gem::Requirement
|
@@ -100,15 +114,16 @@ executables: []
|
|
100
114
|
extensions: []
|
101
115
|
extra_rdoc_files: []
|
102
116
|
files:
|
117
|
+
- GettingStarted.md
|
103
118
|
- History.md
|
104
119
|
- LICENSE.txt
|
105
|
-
- Presentability.md
|
106
|
-
- Presenter.md
|
107
120
|
- README.md
|
108
121
|
- lib/presentability.rb
|
109
122
|
- lib/presentability/presenter.rb
|
123
|
+
- lib/roda/plugins/presenters.rb
|
110
124
|
- spec/presentability/presenter_spec.rb
|
111
125
|
- spec/presentability_spec.rb
|
126
|
+
- spec/roda/plugins/presenters_spec.rb
|
112
127
|
- spec/spec_helper.rb
|
113
128
|
homepage: https://hg.sr.ht/~ged/Presentability
|
114
129
|
licenses:
|
@@ -134,7 +149,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
134
149
|
- !ruby/object:Gem::Version
|
135
150
|
version: '0'
|
136
151
|
requirements: []
|
137
|
-
rubygems_version: 3.
|
152
|
+
rubygems_version: 3.5.3
|
138
153
|
signing_key:
|
139
154
|
specification_version: 4
|
140
155
|
summary: Facade-based presenters with minimal assumptions.
|
metadata.gz.sig
CHANGED
Binary file
|
data/Presentability.md
DELETED
@@ -1,105 +0,0 @@
|
|
1
|
-
|
2
|
-
Facade-based presenter toolkit with minimal assumptions.
|
3
|
-
|
4
|
-
## Basic Usage
|
5
|
-
|
6
|
-
Basic usage of Presentability requires two steps: declaring presenters and
|
7
|
-
then using them.
|
8
|
-
|
9
|
-
### Declaring Presenters
|
10
|
-
|
11
|
-
Presenters are just regular Ruby classes with some convenience methods for
|
12
|
-
declaring exposures, but in a lot of cases you'll want to declare them all in
|
13
|
-
one place. Presentability offers a mixin that implements a simple DSL for
|
14
|
-
declaring presenters and their associations to entity classes, intended to be
|
15
|
-
used in a container module:
|
16
|
-
|
17
|
-
require 'presentability'
|
18
|
-
|
19
|
-
module Acme::Presenters
|
20
|
-
extend Presentability
|
21
|
-
|
22
|
-
presenter_for( Acme::Widget ) do
|
23
|
-
expose :sku
|
24
|
-
expose :name
|
25
|
-
expose :unit_price
|
26
|
-
end
|
27
|
-
|
28
|
-
end
|
29
|
-
|
30
|
-
The block of `presenter_for` is evaluated in the context of a new Presenter
|
31
|
-
class, so refer to that documentation for what's possible there.
|
32
|
-
|
33
|
-
Sometimes you can't (or don't want to) have to load the entity class to
|
34
|
-
declare a presenter for it, so you can also declare it using the class's name:
|
35
|
-
|
36
|
-
presenter_for( 'Acme::Widget' ) do
|
37
|
-
expose :sku
|
38
|
-
expose :name
|
39
|
-
expose :unit_price
|
40
|
-
end
|
41
|
-
|
42
|
-
|
43
|
-
### Using Presenters
|
44
|
-
|
45
|
-
You use presenters by instantiating them with the object they are a facade for
|
46
|
-
(the "subject"), and then applying it:
|
47
|
-
|
48
|
-
acme_widget = Acme::Widget.new(
|
49
|
-
sku: "FF-2237H455",
|
50
|
-
name: "Throbbing Frobnulator",
|
51
|
-
unit_price: 299,
|
52
|
-
inventory_count: 301,
|
53
|
-
wholesale_cost: 39
|
54
|
-
)
|
55
|
-
presentation = Acme::Presenters.present( acme_widget )
|
56
|
-
# => { :sku => "FF-2237H455", :name => "Throbbing Frobnulator", :unit_price => 299 }
|
57
|
-
|
58
|
-
If you want to present a collection of objects as a collection, you can apply presenters to the collection instead:
|
59
|
-
|
60
|
-
widgets_in_stock = Acme::Widget.where { inventory_count > 0 }
|
61
|
-
collection_presentation = Acme::Presenters.present_collection( widgets_in_stock )
|
62
|
-
# => [ {:sku => "FF-2237H455", [...]}, {:sku => "FF-2237H460", [...]}, [...] ]
|
63
|
-
|
64
|
-
The collection can be anything that is `Enumerable`.
|
65
|
-
|
66
|
-
|
67
|
-
### Presentation Options
|
68
|
-
|
69
|
-
Sometimes you want a bit more flexibility in what you present, allowing a single uniform presenter to be used in multiple use cases. To facilitate this, you can pass an options keyword hash to `#present`:
|
70
|
-
|
71
|
-
presenter_for( 'Acme::Widget' ) do
|
72
|
-
expose :sku
|
73
|
-
expose :name
|
74
|
-
expose :unit_price
|
75
|
-
|
76
|
-
# Only expose the wholesale cost if presented via an internal API
|
77
|
-
expose :wholesale_cost, if: :internal_api
|
78
|
-
end
|
79
|
-
|
80
|
-
acme_widget = Acme::Widget.new(
|
81
|
-
sku: "FF-2237H455",
|
82
|
-
name: "Throbbing Frobnulator",
|
83
|
-
unit_price: 299,
|
84
|
-
inventory_count: 301,
|
85
|
-
wholesale_cost: 39
|
86
|
-
)
|
87
|
-
|
88
|
-
# External API remains unchanged:
|
89
|
-
presentation = Acme::Presenters.present( acme_widget )
|
90
|
-
# => { :sku => "FF-2237H455", :name => "Throbbing Frobnulator", :unit_price => 299 }
|
91
|
-
|
92
|
-
# But when run from an internal service:
|
93
|
-
internal_presentation = Acme::Presenters.present( acme_widget, internal_api: true )
|
94
|
-
# => { :sku => "FF-2237H455", :name => "Throbbing Frobnulator", :unit_price => 299,
|
95
|
-
# :wholesale_cost => 39 }
|
96
|
-
|
97
|
-
There are some options that are set for you:
|
98
|
-
|
99
|
-
<dl>
|
100
|
-
<td><code>:in_collection</code></td>
|
101
|
-
<dd>Set if the current object is being presented as part of a collection.</dd>
|
102
|
-
</dl>
|
103
|
-
|
104
|
-
|
105
|
-
|
data/Presenter.md
DELETED
@@ -1,109 +0,0 @@
|
|
1
|
-
|
2
|
-
A presenter (facade) base class.
|
3
|
-
|
4
|
-
|
5
|
-
### Declaring Presenters
|
6
|
-
|
7
|
-
When you declare a presenter in a Presentability collection, the result is a
|
8
|
-
subclass of Presentability::Presenter. The main way of defining a Presenter's
|
9
|
-
functionality is via the ::expose method, which marks an attribute of the underlying
|
10
|
-
entity object (the "subject") for exposure.
|
11
|
-
|
12
|
-
class MyPresenter < Presentability::Presenter
|
13
|
-
expose :name
|
14
|
-
end
|
15
|
-
|
16
|
-
# Assuming `entity_object' has a "name" attribute...
|
17
|
-
presenter = MyPresenter.new( entity_object )
|
18
|
-
presenter.apply
|
19
|
-
# => { :name => "entity name" }
|
20
|
-
|
21
|
-
|
22
|
-
### Presenter Collections
|
23
|
-
|
24
|
-
Setting up classes manually like this is one option, but Presentability also lets you
|
25
|
-
set them up as a collection, which is what further examples will assume for brevity:
|
26
|
-
|
27
|
-
module MyPresenters
|
28
|
-
extend Presentability
|
29
|
-
|
30
|
-
presenter_for( EntityObject ) do
|
31
|
-
expose :name
|
32
|
-
end
|
33
|
-
|
34
|
-
end
|
35
|
-
|
36
|
-
|
37
|
-
### Complex Exposures
|
38
|
-
|
39
|
-
Sometimes you want to do more than just use the presented entity's values as-is. There are a number of ways to do this.
|
40
|
-
|
41
|
-
The first of these is to provide a block when exposing an attribute. The subject of the presenter is available to the block via the `subject` method:
|
42
|
-
|
43
|
-
require 'time'
|
44
|
-
|
45
|
-
presenter_for( LogEvent ) do
|
46
|
-
# Turn Time objects into RFC2822-formatted time strings
|
47
|
-
expose :timestamp do
|
48
|
-
self.subject.timestamp.rfc2822
|
49
|
-
end
|
50
|
-
|
51
|
-
end
|
52
|
-
|
53
|
-
You can also declare the exposure using a regular method with the same name:
|
54
|
-
|
55
|
-
require 'time'
|
56
|
-
|
57
|
-
presenter_for( LogEvent ) do
|
58
|
-
# Turn Time objects into RFC2822-formatted time strings
|
59
|
-
expose :timestamp
|
60
|
-
|
61
|
-
def timestamp
|
62
|
-
return self.subject.timestamp.rfc2822
|
63
|
-
end
|
64
|
-
|
65
|
-
end
|
66
|
-
|
67
|
-
This can be used to add presence checks:
|
68
|
-
|
69
|
-
require 'time'
|
70
|
-
|
71
|
-
presenter_for( LogEvent ) do
|
72
|
-
# Require that presented entities have an `id` attribute
|
73
|
-
expose :id do
|
74
|
-
id = self.subject.id or raise "no `id' for %p" % [ self.subject ]
|
75
|
-
raise "`id' for %p is blank!" % [ self.subject ] if id.blank?
|
76
|
-
|
77
|
-
return id
|
78
|
-
end
|
79
|
-
end
|
80
|
-
|
81
|
-
or conditional exposures:
|
82
|
-
|
83
|
-
presenter_for( Acme::Product ) do
|
84
|
-
|
85
|
-
# Truncate the long description if presented as part of a collection
|
86
|
-
expose :detailed_description do
|
87
|
-
desc = self.subject.detailed_description
|
88
|
-
if self.options[:in_collection]
|
89
|
-
return desc[0..15] + '...'
|
90
|
-
else
|
91
|
-
return desc
|
92
|
-
end
|
93
|
-
end
|
94
|
-
|
95
|
-
end
|
96
|
-
|
97
|
-
|
98
|
-
### Exposure Aliases
|
99
|
-
|
100
|
-
If you want to expose a field but use a different name in the resulting data structure, you can use the `:as` option in the exposure declaration:
|
101
|
-
|
102
|
-
presenter_for( LogEvent ) do
|
103
|
-
expose :timestamp, as: :created_at
|
104
|
-
end
|
105
|
-
|
106
|
-
presenter = MyPresenter.new( log_event )
|
107
|
-
presenter.apply
|
108
|
-
# => { :created_at => '2023-02-01 12:34:02.155365 -0800' }
|
109
|
-
|