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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c1802a45773fa3f0e4662926ae1067ae729e81b88201437f7e4bf9705ceac5ec
4
- data.tar.gz: c0dedfb32ced019e2105b4434816610598f5f7b560ffab7353144955b9f4a9f3
3
+ metadata.gz: a47d99629c4294cef8b308ac17e98f5be3967d9c2663caab2ab633c81d512d2e
4
+ data.tar.gz: 0c325f17f38543a9a7420a9e1ecf727140ecfdc449853ea434f7643343b710e3
5
5
  SHA512:
6
- metadata.gz: c3c3eb84c0c800517b515defb1b8717f1f940d4c582cb63baf5f7689df9aeb47d63a956f997ba64238301f1db88447cffff972a23faa3fad0026b37b3a0b872b
7
- data.tar.gz: 9305b383f0f617186e27411fd153a0aa65b430ae96264c70bc3cc6b9ba99e4d9da77a4e817a9cb2e0870be2c057ed17249a6fad5c1fdd4985edb94b3b4232d3e
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
- # :include: Presenter.md
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 ) unless
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
@@ -3,14 +3,118 @@
3
3
  require 'loggability'
4
4
 
5
5
 
6
- # :include: Presentability.md
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.5.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
+
@@ -11,9 +11,7 @@ RSpec.describe Presentability do
11
11
 
12
12
  let( :entity_class ) do
13
13
  Class.new do
14
- def self::name
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
- def self::name
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
- def self::name
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( 'Acme::Entity' ) do
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.5.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+DCCAmCgAwIBAgIBBTANBgkqhkiG9w0BAQsFADAiMSAwHgYDVQQDDBdnZWQv
14
- REM9RmFlcmllTVVEL0RDPW9yZzAeFw0yMzAxMTYxNzE2MDlaFw0yNDAxMTYxNzE2
15
- MDlaMCIxIDAeBgNVBAMMF2dlZC9EQz1GYWVyaWVNVUQvREM9b3JnMIIBojANBgkq
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
- 9w0BAQsFAAOCAYEARYCeUVBWARNKqF0cvNnLJvFf4hdW2+Rtc7NfC5jQvX9a1oom
27
- sfVvS96eER/9cbrphu+vc59EELw4zT+RY3/IesnoE7CaX6zIOFmSmG7K61OHsSLR
28
- KqMygcWwyuPXT2JG7JsGHuxbzgaRWe29HbSjBbLYxiMH8Zxh4tKutxzKF7jb0Ggq
29
- KAf9MH5LwG8IHVIfV5drT14PvgR3tcvmrn1timPyJl+eZ3LNnm9ofOCweuZCq1cy
30
- 4Q8LV3vP2Cofy9q+az3DHdaUGlmMiZZZqKixDr1KSS9nvh0ZrKMOUL1sWj/IaxrQ
31
- RV3y6td14q49t+xnbj00hPlbW7uE2nLJLt2NAoXiE1Nonndz1seB2c6HL79W9fps
32
- E/O12pQjCp/aPUZMt8/8tKW31RIy/KP8XO6OTJNWA8A/oNEI0g5p/LmmEtJKWYr1
33
- WmEdESlpWhzFECctefIF2lsN9vaOuof57RM77t2otrtcscDtNarIqjZsIyqtDvtL
34
- DttITiit0Vwz7bY0
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: 2023-11-06 00:00:00.000000000 Z
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.4.21
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
-