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 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
-