presentability 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
-
|