cuprum-rails 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (81) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +98 -0
  3. data/CODE_OF_CONDUCT.md +132 -0
  4. data/DEVELOPMENT.md +28 -0
  5. data/LICENSE +22 -0
  6. data/README.md +1045 -0
  7. data/lib/cuprum/rails/action.rb +45 -0
  8. data/lib/cuprum/rails/actions/create.rb +49 -0
  9. data/lib/cuprum/rails/actions/destroy.rb +22 -0
  10. data/lib/cuprum/rails/actions/edit.rb +22 -0
  11. data/lib/cuprum/rails/actions/index.rb +55 -0
  12. data/lib/cuprum/rails/actions/new.rb +19 -0
  13. data/lib/cuprum/rails/actions/resource_action.rb +75 -0
  14. data/lib/cuprum/rails/actions/show.rb +22 -0
  15. data/lib/cuprum/rails/actions/update.rb +59 -0
  16. data/lib/cuprum/rails/actions.rb +16 -0
  17. data/lib/cuprum/rails/collection.rb +115 -0
  18. data/lib/cuprum/rails/command.rb +137 -0
  19. data/lib/cuprum/rails/commands/assign_one.rb +66 -0
  20. data/lib/cuprum/rails/commands/build_one.rb +55 -0
  21. data/lib/cuprum/rails/commands/destroy_one.rb +43 -0
  22. data/lib/cuprum/rails/commands/find_many.rb +60 -0
  23. data/lib/cuprum/rails/commands/find_matching.rb +121 -0
  24. data/lib/cuprum/rails/commands/find_one.rb +50 -0
  25. data/lib/cuprum/rails/commands/insert_one.rb +41 -0
  26. data/lib/cuprum/rails/commands/update_one.rb +49 -0
  27. data/lib/cuprum/rails/commands/validate_one.rb +68 -0
  28. data/lib/cuprum/rails/commands.rb +18 -0
  29. data/lib/cuprum/rails/controller.rb +50 -0
  30. data/lib/cuprum/rails/controller_action.rb +121 -0
  31. data/lib/cuprum/rails/controllers/class_methods/actions.rb +57 -0
  32. data/lib/cuprum/rails/controllers/class_methods/configuration.rb +64 -0
  33. data/lib/cuprum/rails/controllers/class_methods/validations.rb +30 -0
  34. data/lib/cuprum/rails/controllers/class_methods.rb +15 -0
  35. data/lib/cuprum/rails/controllers/configuration.rb +53 -0
  36. data/lib/cuprum/rails/controllers.rb +10 -0
  37. data/lib/cuprum/rails/errors/missing_parameters.rb +33 -0
  38. data/lib/cuprum/rails/errors/missing_primary_key.rb +46 -0
  39. data/lib/cuprum/rails/errors/undefined_permitted_attributes.rb +34 -0
  40. data/lib/cuprum/rails/errors.rb +8 -0
  41. data/lib/cuprum/rails/map_errors.rb +44 -0
  42. data/lib/cuprum/rails/query.rb +77 -0
  43. data/lib/cuprum/rails/query_builder.rb +78 -0
  44. data/lib/cuprum/rails/repository.rb +44 -0
  45. data/lib/cuprum/rails/request.rb +105 -0
  46. data/lib/cuprum/rails/resource.rb +145 -0
  47. data/lib/cuprum/rails/responders/actions.rb +73 -0
  48. data/lib/cuprum/rails/responders/html/plural_resource.rb +62 -0
  49. data/lib/cuprum/rails/responders/html/singular_resource.rb +59 -0
  50. data/lib/cuprum/rails/responders/html.rb +11 -0
  51. data/lib/cuprum/rails/responders/html_responder.rb +129 -0
  52. data/lib/cuprum/rails/responders/json/resource.rb +60 -0
  53. data/lib/cuprum/rails/responders/json.rb +10 -0
  54. data/lib/cuprum/rails/responders/json_responder.rb +122 -0
  55. data/lib/cuprum/rails/responders/matching.rb +145 -0
  56. data/lib/cuprum/rails/responders/serialization.rb +36 -0
  57. data/lib/cuprum/rails/responders.rb +15 -0
  58. data/lib/cuprum/rails/responses/html/redirect_response.rb +29 -0
  59. data/lib/cuprum/rails/responses/html/render_response.rb +52 -0
  60. data/lib/cuprum/rails/responses/html.rb +11 -0
  61. data/lib/cuprum/rails/responses/json_response.rb +29 -0
  62. data/lib/cuprum/rails/responses.rb +11 -0
  63. data/lib/cuprum/rails/routes.rb +166 -0
  64. data/lib/cuprum/rails/routing/plural_routes.rb +26 -0
  65. data/lib/cuprum/rails/routing/singular_routes.rb +24 -0
  66. data/lib/cuprum/rails/routing.rb +11 -0
  67. data/lib/cuprum/rails/rspec/command_contract.rb +460 -0
  68. data/lib/cuprum/rails/rspec/define_route_contract.rb +84 -0
  69. data/lib/cuprum/rails/rspec.rb +8 -0
  70. data/lib/cuprum/rails/serializers/json/active_record_serializer.rb +24 -0
  71. data/lib/cuprum/rails/serializers/json/array_serializer.rb +40 -0
  72. data/lib/cuprum/rails/serializers/json/attributes_serializer.rb +217 -0
  73. data/lib/cuprum/rails/serializers/json/error_serializer.rb +24 -0
  74. data/lib/cuprum/rails/serializers/json/hash_serializer.rb +44 -0
  75. data/lib/cuprum/rails/serializers/json/identity_serializer.rb +21 -0
  76. data/lib/cuprum/rails/serializers/json/serializer.rb +66 -0
  77. data/lib/cuprum/rails/serializers/json.rb +40 -0
  78. data/lib/cuprum/rails/serializers.rb +10 -0
  79. data/lib/cuprum/rails/version.rb +59 -0
  80. data/lib/cuprum/rails.rb +31 -0
  81. metadata +286 -0
data/README.md ADDED
@@ -0,0 +1,1045 @@
1
+ # Cuprum::Rails
2
+
3
+ An integration between Rails and the Cuprum library.
4
+
5
+ Cuprum::Rails defines the following objects:
6
+
7
+ - [Collections](#collections): A collection for performing operations on ActiveRecord models using the standard `Cuprum::Collections` interface.
8
+ - [Commands](#commands): Each collection is comprised of `Cuprum` commands, which implement common collection operations such as inserting or querying data.
9
+ - [Controllers](#controllers): Decouples controller responsibilities for precise control, reusability, and reduction of boilerplate code.
10
+ - [Actions](#actions): Implement a controller's actions as a `Cuprum` command.
11
+ - [Requests](#requests): Encapsulates a controller request.
12
+ - [Resources](#resources) and [Routes](#routes): Configuration for a resourceful controller.
13
+ - [Responders](#responders) and [Responses](#responses): Generate controller responses from action results.
14
+ - [Serializers](#serializers): Recursively convert entities and data structures into serialized data.
15
+
16
+ ## About
17
+
18
+ Cuprum::Rails provides a toolkit for using the Cuprum command pattern and the flexibility of Cuprum::Collections to build Rails applications. Using the `Cuprum::Rails::Collection`, you can perform operations on ActiveRecord models, leveraging a standard interface to control where your data is stored and how it is queried. For example, you can inject a mock collection into unit tests for precise control over queried values and blinding fast tests without having to hit the database directly.
19
+
20
+ Using `Cuprum::Rails::Controller` takes this one step further, breaking apart the traditional controller into a sequence of steps with individual responsibilities. This has two main benefits. First, being explicit about how your controllers perform and respond to actions allows for precise control at each step of the process. Second, each step is encapsulated, which allows for easier testing and reuse. This not only makes testing simpler - you can test your business logic by examining an Action result, rather than parsing a rendered HTML page - but allows you to reuse individual components. The goal is to reduce the boilerplate inherent in writing a Rails application by allowing you to define only the code that is unique to the controller, action, or process.
21
+
22
+ ### Why Cuprum::Rails?
23
+
24
+ Rails is a highly opinionated framework: one of the pillars of The Rails Doctrine is the principle that "The menu is omakase". This is one of the keys to the framework's success, providing a welcoming environment for new developers as well as powerful tools for developing applications - as long as those applications are built The Rails Way.
25
+
26
+ This is great for rapidly developing prototypes, proof of concept or proof of market applications, or even smaller applications for content management, e-commerce, and so on. There are good reasons why Rails has made so much headway against established behemoths such as WordPress. That being said, many companies are using Rails to build applications that are much more ambitious, and at that scale the standard Rails patterns start to fall apart. Omakase is no longer just right.
27
+
28
+ Cuprum::Rails is intended to address two of the pain points of Big Rails. The first is architectural: any Rails developer of a certain age will remember the wars over Fat Controllers versus Fat Models. The rise of Service Objects provides a way forward, but in practice this can be something of a Wild West - everything gets dumped in an `app/services` directory, each file looks and works differently. The [Cuprum](github.com/sleepingkingstudios/cuprum) gem is designed to provide a solution to this chaos. Defining a command gives you the benefits of encapsulation, control flow, and *consistency* - every command defines one `#call` method and returns a result.
29
+
30
+ The second benefit is *reusability*. Breaking down a controller into its constituent steps means you don't have to reimplement each of those steps each time you create a controller or add an action. You can define what it means to respond to an HTML or JSON request once, and modify it on a per-action basis when you need custom behavior. You can subclass the resourceful action commands to leverage basic controller functionality, such as performing filtered queries. And, of course, you gain all the benefits of decoupling commands from your controller - you can use the same functionality in a controller action, as an asynchronous job, or as a command-line function.
31
+
32
+ ### Compatibility
33
+
34
+ Cuprum::Collections is tested against Ruby (MRI) 2.6 through 3.0.
35
+
36
+ ### Documentation
37
+
38
+ Documentation is generated using [YARD](https://yardoc.org/), and can be generated locally using the `yard` gem.
39
+
40
+ ### License
41
+
42
+ Copyright (c) 2021 Rob Smith
43
+
44
+ Stannum is released under the [MIT License](https://opensource.org/licenses/MIT).
45
+
46
+ ### Contribute
47
+
48
+ The canonical repository for this gem is located at https://github.com/sleepingkingstudios/cuprum-rails.
49
+
50
+ To report a bug or submit a feature request, please use the [Issue Tracker](https://github.com/sleepingkingstudios/cuprum-rails/issues).
51
+
52
+ To contribute code, please fork the repository, make the desired updates, and then provide a [Pull Request](https://github.com/sleepingkingstudios/cuprum-rails/pulls). Pull requests must include appropriate tests for consideration, and all code must be properly formatted.
53
+
54
+ ### Code of Conduct
55
+
56
+ Please note that the `Cuprum::Collections` project is released with a [Contributor Code of Conduct](https://github.com/sleepingkingstudios/cuprum-rails/blob/master/CODE_OF_CONDUCT.md). By contributing to this project, you agree to abide by its terms.
57
+
58
+ <!-- ## Getting Started -->
59
+
60
+ ## Reference
61
+
62
+ <a id="collections"></a>
63
+
64
+ ### Collections
65
+
66
+ ```ruby
67
+ require 'cuprum/rails/collection'
68
+ ```
69
+
70
+ A `Cuprum::Rails::Collection` implements the [Cuprum::Collections](https://github.com/sleepingkingstudios/cuprum-collections) interface for `ActiveRecord` models. It defines a set of [commands](#commands) that implement persistence and query operations, and a `#query` method to directly perform queries on the data.
71
+
72
+ ```ruby
73
+ collection = Cuprum::Rails::Collection.new(record_class: Book)
74
+
75
+ # Add an item to the collection.
76
+ steps do
77
+ # Build the book from attributes.
78
+ book = step do
79
+ collection.build_one.call(
80
+ attributes: { id: 10, title: 'Gideon the Ninth', author: 'Tamsyn Muir' }
81
+ )
82
+ end
83
+
84
+ # Validate the book using its default validations.
85
+ step { collection.validate_one.call(entity: book) }
86
+
87
+ # Insert the validated book to the collection.
88
+ step { collection.insert_one.call(entity: book) }
89
+ end
90
+
91
+ # Find an item by primary key.
92
+ book = step { collection.find_one.call(primary_key: 10) }
93
+
94
+ # Find items matching a filter.
95
+ books = step do
96
+ collection.find_matching.call(
97
+ limit: 10,
98
+ order: [:author, { title: :descending }],
99
+ where: lambda do
100
+ published_at: greater_than('1950-01-01')
101
+ end
102
+ )
103
+ end
104
+ ```
105
+
106
+ Initializing a collection requires the `:record_class` keyword, which should be a Class that inherits from `ActiveRecord::Base`. You can also specify some optional keywords:
107
+
108
+ - The `:collection_name` parameter sets the name of the collection. It is used to create an envelope for query commands, such as the `FindMany`, `FindMatching` and `FindOne` commands.
109
+ - The `:default_contract` parameter sets a default contract for validating collection entities. If no `:contract` keyword is passed to the `ValidateOne` command, it will use the default contract to validate the entity instead of the validation constraints defined for the model.
110
+ - The `:member_name` parameter is used to create an envelope for singular query commands such as the `FindOne` command. If not given, the member name will be generated automatically as a singular form of the collection name.
111
+ - The `:primary_key_name` parameter specifies the attribute that serves as the primary key for the collection entities. The default value is `:id`.
112
+ - The `:primary_key_type` parameter specifies the type of the primary key attribute. The default value is `Integer`.
113
+
114
+ <a id="commands"></a>
115
+
116
+ #### Commands
117
+
118
+ Structurally, a collection is a set of commands, which are instances of `Cuprum::Command` that implement a persistence or querying operation and wrap that operation with parameter validation and error handling. For more information on Cuprum commands, see the [Cuprum gem](github.com/sleepingkingstudios/cuprum).
119
+
120
+ ##### Assign One
121
+
122
+ The `AssignOne` command takes an attributes hash and a record, assigns the given attributes to the record, and returns the record.
123
+
124
+ ```ruby
125
+ book = Book.new('id' => 10, 'title' => 'Gideon the Ninth', 'author' => 'Tamsyn Muir')
126
+ attributes = { 'title' => 'Harrow the Ninth', 'published_at' => '2020-08-04' }
127
+ result = collection.assign_one.call(attributes: attributes, entity: entity)
128
+
129
+ result.value.class
130
+ #=> Book
131
+ result.value.attributes
132
+ #=> {
133
+ # 'id' => 10,
134
+ # 'title' => 'Harrow the Ninth',
135
+ # 'author' => 'Tamsyn Muir',
136
+ # 'series' => nil,
137
+ # 'category' => nil,
138
+ # 'published_at' => '2020-08-04'
139
+ # }
140
+ ```
141
+
142
+ If the attributes hash includes one or more attributes that are not defined for that record class, the `#assign_one` command can return a failing result with an `ExtraAttributes` error.
143
+
144
+ ##### Build One
145
+
146
+ The `BuildOne` command takes an attributes hash and returns a new record whose attributes are equal to the given attributes. This does not validate or persist the record; it is equivalent to calling `record_class.new` with the attributes.
147
+
148
+ ```ruby
149
+ attributes = { 'id' => 10, 'title' => 'Gideon the Ninth', 'author' => 'Tamsyn Muir' }
150
+ result = collection.build_one.call(attributes: attributes, entity: entity)
151
+
152
+ result.value.class
153
+ #=> Book
154
+ result.value.attributes
155
+ #=> {
156
+ # 'id' => 10,
157
+ # 'title' => 'Gideon the Ninth',
158
+ # 'author' => 'Tamsyn Muir',
159
+ # 'series' => nil,
160
+ # 'category' => nil,
161
+ # 'published_at' => nil
162
+ # }
163
+ ```
164
+
165
+ If the attributes hash includes one or more attributes that are not defined for that record class, the `#build_one` command can return a failing result with an `ExtraAttributes` error.
166
+
167
+ ##### Destroy One
168
+
169
+ The `DestroyOne` command takes a primary key value and removes the record with the specified primary key from the collection.
170
+
171
+ ```ruby
172
+ result = collection.destroy_one.call(primary_key: 0)
173
+
174
+ collection.query.where(id: 0).exists?
175
+ #=> false
176
+ ```
177
+
178
+ If the collection does not include a record with the specified primary key, the `#destroy_one` command will return a failing result with a `NotFound` error.
179
+
180
+ ##### Find Many
181
+
182
+ The `FindMany` command takes an array of primary key values and returns the records with the specified primary keys. The entities are returned in the order of the specified primary keys.
183
+
184
+ ```ruby
185
+ result = collection.find_many.call(primary_keys: [0, 1, 2])
186
+ result.value
187
+ #=> [
188
+ # #<Book
189
+ # id: 0,
190
+ # title: 'The Hobbit',
191
+ # author: 'J.R.R. Tolkien',
192
+ # series: nil,
193
+ # category: 'Science Fiction and Fantasy',
194
+ # published_at: '1937-09-21'
195
+ # >,
196
+ # #<Book
197
+ # id: 1,
198
+ # title: 'The Silmarillion',
199
+ # author: 'J.R.R. Tolkien',
200
+ # series: nil,
201
+ # category: 'Science Fiction and Fantasy',
202
+ # published_at: '1977-09-15'
203
+ # >,
204
+ # #<Book
205
+ # id: 2,
206
+ # title: 'The Fellowship of the Ring',
207
+ # author: 'J.R.R. Tolkien',
208
+ # series: 'The Lord of the Rings',
209
+ # category: 'Science Fiction and Fantasy',
210
+ # published_at: '1954-07-29'
211
+ # >
212
+ # ]
213
+ ```
214
+
215
+ The `FindMany` command has several options:
216
+
217
+ - The `:allow_partial` keyword allows the command to return a passing result if at least one of the entities is found. By default, the command will return a failing result unless an entity is found for each primary key value.
218
+ - The `:envelope` keyword wraps the result value in an envelope hash, with a key equal to the name of the collection and whose value is the returned entities array.
219
+
220
+ ```ruby
221
+ result = collection.find_many.call(primary_keys: [0, 1, 2], envelope: true)
222
+ result.value
223
+ #=> { books: [#<Book>, #<Book>, #<Book>] }
224
+ ```
225
+
226
+ - The `:scope` keyword allows you to pass a query to the command. Only entities that match the given scope will be found and returned by `#find_many`.
227
+
228
+ If the collection does not include an entity with each of the specified primary keys, the `#find_many` command will return a failing result with a `NotFound` error.
229
+
230
+ ##### Find Matching
231
+
232
+ The `FindMatching` command takes a set of query parameters and queries data from the collection. You can specify filters using the `:where` keyword or by passing a block, sort the results using the `:order` keyword, or return a subset of the results using the `:limit` and `:offset` keywords. For full details on performing queries, see [Queries](#queries), below.
233
+
234
+ ```ruby
235
+ result =
236
+ collection
237
+ .find_matching
238
+ .call(order: :published_at, where: { series: 'Earthsea' })
239
+ result.value
240
+ #=> [
241
+ # #<Book
242
+ # id: 7,
243
+ # title: 'A Wizard of Earthsea',
244
+ # author: 'Ursula K. LeGuin',
245
+ # series: 'Earthsea',
246
+ # category: 'Science Fiction and Fantasy',
247
+ # published_at: '1968-11-01'
248
+ # >,
249
+ # #<Book
250
+ # id: 8,
251
+ # title: 'The Tombs of Atuan',
252
+ # author: 'Ursula K. LeGuin',
253
+ # series: 'Earthsea',
254
+ # category: 'Science Fiction and Fantasy',
255
+ # published_at: '1970-12-01'
256
+ # >,
257
+ # #<Book
258
+ # id: 9,
259
+ # title: 'The Farthest Shore',
260
+ # author: 'Ursula K. LeGuin',
261
+ # series: 'Earthsea',
262
+ # category: 'Science Fiction and Fantasy',
263
+ # published_at: '1972-09-01'
264
+ # >
265
+ # ]
266
+ ```
267
+
268
+ The `FindMatching` command has several options:
269
+
270
+ - The `:envelope` keyword wraps the result value in an envelope hash, with a key equal to the name of the collection and whose value is the returned entities array.
271
+
272
+ ```ruby
273
+ result = collection.find_matching.call(where: { series: 'Earthsea' }, envelope: true)
274
+ result.value
275
+ #=> { books: [#<Book>, #<Book>, #<Book>] }
276
+ ```
277
+
278
+ - The `:limit` keyword caps the number of results returned.
279
+ - The `:offset` keyword skips the specified number of results.
280
+ - The `:order` keyword specifies the order of results.
281
+ - The `:scope` keyword allows you to pass a query to the command. Only entities that match the given scope will be found and returned by `#find_matching`.
282
+ - The `:where` keyword defines filters for which results are to be returned.
283
+
284
+ ##### Find One
285
+
286
+ The `FindOne` command takes a primary key value and returns the record with the specified primary key.
287
+
288
+ ```ruby
289
+ result = collection.find_one.call(primary_key: 1)
290
+ result.value
291
+ #=> #<Book
292
+ # id: 1,
293
+ # title: 'The Silmarillion',
294
+ # author: 'J.R.R. Tolkien',
295
+ # series: nil,
296
+ # category: 'Science Fiction and Fantasy',
297
+ # published_at: '1977-09-15'
298
+ # >
299
+ ```
300
+
301
+ The `FindOne` command has several options:
302
+
303
+ - The `:envelope` keyword wraps the result value in an envelope hash, with a key equal to the singular name of the collection and whose value is the returned record.
304
+
305
+ ```ruby
306
+ result = collection.find_one.call(primary_key: 1, envelope: true)
307
+ result.value
308
+ #=> { book: #<Book> }
309
+ ```
310
+
311
+ - The `:scope` keyword allows you to pass a query to the command. Only an entity that match the given scope will be found and returned by `#find_one`.
312
+
313
+ If the collection does not include a record with the specified primary key, the `#find_one` command will return a failing result with a `NotFound` error.
314
+
315
+ ##### Insert One
316
+
317
+ The `InsertOne` command takes a record and inserts that record into the collection.
318
+
319
+ ```ruby
320
+ book = Book.new('id' => 10, 'title' => 'Gideon the Ninth', 'author' => 'Tamsyn Muir')
321
+ result = collection.insert_one.call(entity: book)
322
+
323
+ result.value
324
+ #=> #<Book
325
+ # id: 10,
326
+ # title: 'Gideon the Ninth',
327
+ # author: 'Tamsyn Muir',
328
+ # series: nil,
329
+ # category: nil,
330
+ # published_at: nil
331
+ # >
332
+
333
+ collection.query.where(id: 10).exists?
334
+ #=> true
335
+ ```
336
+
337
+ If the collection already includes a record with the specified primary key, the `#insert_one` command will return a failing result with an `AlreadyExists` error.
338
+
339
+ ##### Update One
340
+
341
+ The `UpdateOne` command takes a record and updates the corresponding record in the collection.
342
+
343
+ ```ruby
344
+ book = collection.find_one.call(1).value
345
+ book = book.assign_attributes('author' => 'John Ronald Reuel Tolkien')
346
+ result = collection.update_one(entity: book)
347
+
348
+ result.value
349
+ #=> #<Book
350
+ # id: 1,
351
+ # title: 'The Silmarillion',
352
+ # author: 'J.R.R. Tolkien',
353
+ # series: nil,
354
+ # category: 'Science Fiction and Fantasy',
355
+ # published_at: '1977-09-15'
356
+ # >
357
+
358
+ collection
359
+ .query
360
+ .where(title: 'The Silmarillion', author: 'John Ronald Reuel Tolkien')
361
+ .exists?
362
+ #=> true
363
+ ```
364
+
365
+ If the collection does not include a record with the specified records's primary key, the `#update_one` command will return a failing result with a `NotFound` error.
366
+
367
+ ##### Validate One
368
+
369
+ The `ValidateOne` command takes an entity and an optional `Stannum` contract. If the `:contract` keyword is given, the record is matched against the contract; otherwise, the record is matched using the native validations defined for the record class.
370
+
371
+ ```ruby
372
+ book = Book.new('id' => 10, 'title' => 'Gideon the Ninth', 'author' => 'Tamsyn Muir')
373
+ result = collection.validate_one.call(entity: book)
374
+ result.success?
375
+ #=> true
376
+ ```
377
+
378
+ If the contract does not match the entity, the `#validate_one` command will return a failing result with a `ValidationFailed` error.
379
+
380
+ <a id="repositories"></a>
381
+
382
+ #### Repositories
383
+
384
+ ```ruby
385
+ require 'cuprum/rails/repository'
386
+ ```
387
+
388
+ A `Cuprum::Rails::Repository` is a group of Rails collections. A single repository might represent all or a subset of the tables in your database.
389
+
390
+ ```ruby
391
+ repository = Cuprum::Collections::Repository.new
392
+ repository.key?('books')
393
+ #=> false
394
+
395
+ repository.add(books_collection)
396
+
397
+ repository.key?('books')
398
+ #=> true
399
+ repository.keys
400
+ #=> ['books']
401
+ repository['books']
402
+ #=> the books collection
403
+ ```
404
+
405
+ <a id="controllers"></a>
406
+
407
+ ### Controllers
408
+
409
+ ```ruby
410
+ require 'cuprum/rails/controller'
411
+ ```
412
+
413
+ > **Important Note**
414
+ >
415
+ > `Cuprum::Rails` is a pre-release gem, and there may be breaking changes between minor versions and until the API is finalized by version 1.0.0. The `Controller` API is particularly likely to experience changes as additional use cases are discovered and supported.
416
+
417
+ The Rails approach to controllers is to embrace Convention over Configuration. `Cuprum::Rails::Controller` inverts this pattern, using configuration to precisely define behavior.
418
+
419
+ ```ruby
420
+ class BooksController
421
+ include Cuprum::Rails::Controller
422
+
423
+ def self.resource
424
+ @resource ||= Cuprum::Rails::Resource.new(
425
+ collection: Cuprum::Rails::Collection.new(record_class: Book),
426
+ permitted_attributes: %i[title author series category published_at],
427
+ resource_class: Book
428
+ )
429
+ end
430
+
431
+ def self.serializers
432
+ serializers = super()
433
+ json = serializers.fetch(:json, {})
434
+ record_serializer =
435
+ Cuprum::Rails::Serializers::Json::ActiveRecordSerializer.instance
436
+
437
+ serializers.merge(
438
+ json: json.merge(ActiveRecord::Base => record_serializer)
439
+ )
440
+ end
441
+
442
+ responder :html, Cuprum::Rails::Responders::Html::PluralResource
443
+ responder :json, Cuprum::Rails::Responders::Json::Resource
444
+
445
+ action :create, Cuprum::Rails::Actions::Create
446
+ action :destroy, Cuprum::Rails::Actions::Destroy, member: true
447
+ action :edit, Cuprum::Rails::Actions::Edit, member: true
448
+ action :new, Cuprum::Rails::Actions::New
449
+ action :index, Cuprum::Rails::Actions::Index
450
+ action :show, Cuprum::Rails::Actions::Show, member: true
451
+ action :update, Cuprum::Rails::Actions::Update, member: true
452
+ end
453
+ ```
454
+
455
+ Here, we are defining a typical Rails resourceful controller, which implements the CRUD actions for `Book`s and responds to HTML and JSON requests. As you can see, `Cuprum::Rails::Controller` is a mix of [Actions](#actions) and configuration (the [Resource](#resources), [Responders](#responders), and [Serializers](#serializers)). In a full application, some of that configuration (the responders and serializers) could be handled in an abstract base controller, such as an `APIController` that defined a JSON responder and serializers. Note also that the *implementation* of the actions happens elsewhere - the controller references existing commands to define the actions.
456
+
457
+ #### Configuring Controllers
458
+
459
+ Each controller has three main points of configuration: a `Resource`, a set of `Responders`, and a set of `Serializers`.
460
+
461
+ The [Resource](#resources) provides some metadata about the controller, such as a `#resource_name`, a set of `#routes`, and whether the controller represents a singular or a plural resource. Generally speaking, each controller should have a unique resource, which is defined by overriding the `.resource` class method.
462
+
463
+ The [Responders](#responders) determine what request formats are accepted by the controller and how the corresponding responses are generated. Responders can and should be shared between controllers, and are defined using the `.responder` class method. `.responder` takes two parameters: a `format`, which should be either a string or a symbol (e.g. `:json`) and a `responder_class`, which will be used to generate responses for the specified format.
464
+
465
+ The [Serializers](#serializers) are used in API responses (such as a JSON response) to convert application data into a serialized format. `Cuprum::Rails` defines a base set of serializers for simple data; applications can either set a generic serializer for records (as in `BooksController`, above) or set specific serializers for each record class on a per-controller basis. Serializers are defined by overriding the `.serializers` class method - make sure to call `super()` and merge the results, unless you specifically want to override the default values.
466
+
467
+ #### Defining Actions
468
+
469
+ A non-abstract controller should define at least one [Action](#actions), corresponding to a page, process, or API endpoint for the application. Actions are defined using the `.action` class method, which takes two parameters: an `action_name`, which should be either a string or a symbol (e.g. `:publish`), and an `action_class`, which is a subclass of `Cuprum::Rails::Action`.
470
+
471
+ ```ruby
472
+ class BooksController
473
+ action :published, Actions::Books::Published
474
+ end
475
+ ```
476
+
477
+ In addition, `.action` accepts the following keywords:
478
+
479
+ - `:member`: If `true`, the action is a member action and acts on a member of the collection, rather than the collection as a whole. In a classic controller, the `:edit`, `:destroy`, `:show`, and `:update` actions are member actions.
480
+
481
+ ```ruby
482
+ class BooksController
483
+ action :publish, Actions::Books::Publish, member: true
484
+ end
485
+ ```
486
+
487
+ <a id="controllers-action-lifecycle"></a>
488
+
489
+ #### The Action Lifecycle
490
+
491
+ Inside a controller action, `Cuprum::Rails` splits up the responsibilities of responding to a request.
492
+
493
+ 1. The Action
494
+ 1. The `action_class` is initialized, passing the controller `resource` to the constructor and returning the `action`.
495
+ 2. The controller `#request` is wrapped in a `Cuprum::Rails::Request`, which is passed to the `action`'s `#call` method, returning the `result`.
496
+ 2. The Responder
497
+ 1. The `responder_class` is found for the request based on the request's `format` and the configured `responders`.
498
+ 2. The `responder_class` is initialized with the `action_name`, `resource`, and `serializers`, returning the `responder`.
499
+ 3. The `responder` is called with the action `result`, and finds a matching `response` based on the action name, the result's success or failure, and the result error (if any).
500
+ 3. The Response
501
+ 1. The `response` is then called with the controller, which allows it to reference native Rails controller methods for rendering or redirecting.
502
+
503
+ Let's walk through this step by step. We start by making a `POST` request to `/books`, which corresponds to the `BooksController#create` endpoint with parameters `{ book: { title: 'Gideon the Ninth' } }`.
504
+
505
+ 1. The Action
506
+ 1. We initialize our configured action class, which is `Cuprum::Rails::Actions::Index`.
507
+ 2. We wrap the request in a `Cuprum::Rails::Request`, and call our `action` with the wrapped `request`. The action performs the business logic (building, validating, and persisting a new `Book`) and returns an instance of `Cuprum::Result`. In our case, the book's attributes are valid, so the result has a `:status` of `:success` and a value of `{ 'book' => #<Book id: 0, title: 'Gideon the Ninth'> }`.
508
+ 2. The Responder
509
+ 1. We're making an HTML request, so our controller will use the responder configured for the `:html` format. In our case, this is `Cuprum::Rails::Responders::Html::PluralResource`, which defines default behavior for responding to resourceful requests.
510
+ 2. Our `Responders::Html::PluralResource` is initialized, giving us a `responder`.
511
+ 3. The `responder` is called with our `result`. There is a match for a successful `:create` action, which returns an instance of `Cuprum::Rails::Responses::Html::RedirectResponse` with a `path` of `/books/0`.
512
+ 3. The Response
513
+ 1. Finally, our `response` object is called. The `RedirectResponse` directs the controller to redirect to `/books/0`, which is the `:show` page for our newly created `Book`.
514
+
515
+ <a id="actions"></a>
516
+
517
+ ### Actions
518
+
519
+ ```ruby
520
+ require 'cuprum/rails/action'
521
+ ```
522
+
523
+ `Cuprum::Rails` extracts the business logic of controllers into dedicated `Cuprum::Rails::Action`s. Each action is a `Cuprum::Command` that is initialized with a [Resource](#resources), called with a [Request](#request), and returns a `Cuprum::Result` that is then passed to the responder.
524
+
525
+ ```ruby
526
+ class PublishedBooks < Cuprum::Rails::Action
527
+ private
528
+
529
+ def process(request)
530
+ super
531
+
532
+ resource.collection.find_matching.call(order: params[:order]) do
533
+ {
534
+ 'published_at' => not_equal(nil)
535
+ }
536
+ end
537
+ end
538
+ end
539
+ ```
540
+
541
+ Each action has access to the `resource` via the constructor, the `request`, and the request's `params`. Above, we are defining a simple action for returning books that have a non-`nil` publication date. Like any `Cuprum::Command`, the heart of the class is the `#process` method, which for an action takes the `request` as its sole parameter. Inside the method, we call `super` to setup the action. We then access the configured `resource`, which grants us access to the `collection` of books. Finally, we call the collection's `find_matching` command, with an optional ordering coming from the params.
542
+
543
+ The `Cuprum::Rails::Actions::ResourceAction` provides some helper methods for defining resourceful actions.
544
+
545
+ ```ruby
546
+ class PublishBook < Cuprum::Rails::Actions::ResourceAction
547
+ private
548
+
549
+ def process(request)
550
+ book_id = step { resource_id }
551
+ book = step { collection.find_one.call(entity_id: book_id) }
552
+
553
+ book.published_at = DateTime.current
554
+
555
+ step { collection.validate_one.call(entity: book) }
556
+
557
+ step { collection.update_one.call(entity: book) }
558
+ end
559
+ end
560
+ ```
561
+
562
+ `ResourceAction` delegates `#collection`, `#resource_name`, and `#singular_resource_name` to the `#resource`. In addition, it defines the following helper methods. Each method returns a `Cuprum::Result`, so you can use the `#step` control flow to handle command errors.
563
+
564
+ - `#resource_id`: Wraps `params[:id]` in a result, or returns a failing result with a `Cuprum::Rails::Errors::MissingParameters` error.
565
+ - `#resource_params`: Wraps `params[singular_resource_name]` and filters them using `resource.permitted_attributes`. Returns a failing result with a `Cuprum::Rails::Errors::MissingParameters` error if the resource params are missing, or with a `Cuprum::Rails::Errors::UndefinedPermittedAttributes` error if the resource does not define permitted attributes.
566
+
567
+ `Cuprum::Rails` also provides some pre-defined actions to implement classic resourceful controllers. Each resource action calls one or more commands from the resource collection to query or persist the record or records.
568
+
569
+ #### Create
570
+
571
+ The `Create` action passes the resource params to `collection.build_one`, validates the record using `collection.validate_one`, and finally inserts the new record into the collection using the `collection.insert_one` command. The action returns a Hash containing the created record.
572
+
573
+ ```ruby
574
+ action = Cuprum::Rails::Actions::Create.new(resource)
575
+ attributes = { 'book' => { 'title' => 'Gideon the Ninth' } }
576
+ result = action.call(request)
577
+ result.success?
578
+ #=> true
579
+ result.value
580
+ #=> { 'book' => #<Book title: 'Gideon the Ninth'> }
581
+
582
+ Book.where(title: 'Gideon the Ninth').exist?
583
+ #=> true
584
+ ```
585
+
586
+ If the created record is not valid, the action returns a failing result with a `Cuprum::Collections::Errors::FailedValidation` error.
587
+
588
+ If the params do not include attributes for the resource, the action returns a failing result with a `Cuprum::Rails::Errors::MissingParameters` error.
589
+
590
+ If the permitted attributes are not defined for the resource, the action returns a failing result with a `Cuprum::Rails::Errors::UndefinedPermittedAttributes` error.
591
+
592
+ #### Destroy
593
+
594
+ The `Destroy` action removes the record from the collection via `collection.destroy_one`. The action returns a Hash containing the deleted record.
595
+
596
+ ```ruby
597
+ action = Cuprum::Rails::Actions::Destroy.new(resource)
598
+ attributes = { 'id' => 0 }
599
+ result = action.call(request)
600
+ result.success?
601
+ #=> true
602
+ result.value
603
+ #=> { 'book' => #<Book id: 0> }
604
+
605
+ Book.where(id: 0).exist?
606
+ #=> false
607
+ ```
608
+
609
+ If the record with the given primary key does not exist, the action returns a failing result with a `Cuprum::Collections::Errors::NotFound` error.
610
+
611
+ #### Edit
612
+
613
+ The `Edit` action finds the record with the given primary key via `collection.find_one` and returns a Hash containing the found record.
614
+
615
+ ```ruby
616
+ action = Cuprum::Rails::Actions::Edit.new(resource)
617
+ attributes = { 'id' => 0 }
618
+ result = action.call(request)
619
+ result.success?
620
+ #=> true
621
+ result.value
622
+ #=> { 'book' => #<Book id: 0> }
623
+ ```
624
+
625
+ If the record with the given primary key does not exist, the action returns a failing result with a `Cuprum::Collections::Errors::NotFound` error.
626
+
627
+ #### Index
628
+
629
+ The `Index` action performs a query on the records using `collection.find_matching`, and returns a Hash containing the found records. You can pass `:limit`, `:offset`, `:order`, and `:where` parameters to filter the results.
630
+
631
+ ```ruby
632
+ action = Cuprum::Rails::Actions::Index.new(resource)
633
+ attributes = {
634
+ 'limit' => 3,
635
+ 'order' => { 'title' => :asc },
636
+ 'where' => { 'author' => 'Ursula K. LeGuin' }
637
+ }
638
+ result = action.call(request)
639
+ result.success?
640
+ #=> true
641
+ result.value
642
+ #=> { 'books' => [#<Book>, #<Book>, #<Book>] }
643
+ ```
644
+
645
+ #### New
646
+
647
+ The `New` action builds a new record with empty attributes using `collection.build_one`, and returns a Hash containing the new record.
648
+
649
+ ```ruby
650
+ action = Cuprum::Rails::Actions::New.new(resource)
651
+ result = action.call(request)
652
+ result.success?
653
+ #=> true
654
+ result.value
655
+ #=> { 'book' => #<Book> }
656
+ ```
657
+
658
+ #### Show
659
+
660
+ The `Show` action finds the record with the given primary key via `collection.find_one` and returns a Hash containing the found record.
661
+
662
+ ```ruby
663
+ action = Cuprum::Rails::Actions::Show.new(resource)
664
+ attributes = { 'id' => 0 }
665
+ result = action.call(request)
666
+ result.success?
667
+ #=> true
668
+ result.value
669
+ #=> { 'book' => #<Book id: 0> }
670
+ ```
671
+
672
+ If the record with the given primary key does not exist, the action returns a failing result with a `Cuprum::Collections::Errors::NotFound` error.
673
+
674
+ #### Update
675
+
676
+ The `Update` action finds the record with the given primary key via `collection.find_one`, assigns the given attributes using `collection.assign_one`, validates the record using `collection.validate_one`, and finally updates the record in the collection using the `collection.update_one` command. The action returns a Hash containing the created record.
677
+
678
+ ```ruby
679
+ action = Cuprum::Rails::Actions::Update.new(resource)
680
+ attributes = { 'id' => 0, 'book' => { 'title' => 'Gideon the Ninth' } }
681
+ result = action.call(request)
682
+ result.success?
683
+ #=> true
684
+ result.value
685
+ #=> { 'book' => #<Book id: 0, title: 'Gideon the Ninth'> }
686
+
687
+ Book.find(0).title
688
+ #=> 'Gideon the Ninth'
689
+ ```
690
+
691
+ If the record with the given primary key does not exist, the action returns a failing result with a `Cuprum::Collections::Errors::NotFound` error.
692
+
693
+ If the updated record is not valid, the action returns a failing result with a `Cuprum::Collections::Errors::FailedValidation` error.
694
+
695
+ If the params do not include attributes for the resource, the action returns a failing result with a `Cuprum::Rails::Errors::MissingParameters` error.
696
+
697
+ If the permitted attributes are not defined for the resource, the action returns a failing result with a `Cuprum::Rails::Errors::UndefinedPermittedAttributes` error.
698
+
699
+ <a id="requests"></a>
700
+
701
+ ### Requests
702
+
703
+ ```ruby
704
+ require 'cuprum/rails/request'
705
+ ```
706
+
707
+ A `Cuprum::Rails::Request` is a value object that encapsulates the details of a controller request, such as the request `format`, the `headers`, and the `parameters`. Generally speaking, users should not instantiate requests directly; they are used as part of the [Controller action lifecycle](#controllers-action-lifecycle).
708
+
709
+ Each request defines the following properties:
710
+
711
+ - `#authorization`: The value of the `"AUTHORIZATION"` header, if any, as a `String`.
712
+ - `#body_parameters`: (also `#body_params`) The parameters derived from the request body, such as a JSON payload or form data. A `Hash` with `String` keys.
713
+ - `#format`: The format of the request as a `Symbol`, e.g. `:html` or `:json`.
714
+ - `#headers`: The request headers, as a `Hash` with `String` keys.
715
+ - `#method`: The HTTP method used for the request as a `Symbol`, e.g. `:get` or `:post`.
716
+ - `#parameters`: (also `#params`) The complete parameters for the request, including both params from the request body and from the query string. A `Hash` with `String` keys.
717
+ - `#path`: The relative path of the request, including query params.
718
+ - `#query_parameters`: (also `#query_params`) The query parameters for the request. A `Hash` with `String` keys.
719
+
720
+ <a id="resources"></a>
721
+
722
+ ### Resources
723
+
724
+ ```ruby
725
+ require 'cuprum/rails/resource'
726
+ ```
727
+
728
+ A `Cuprum::Rails::Resource` defines the configuration for a resourceful controller.
729
+
730
+ ```ruby
731
+ resource = Cuprum::Rails::Resource.new(
732
+ collection: Cuprum::Rails::Collection.new(record_class: Book),
733
+ resource_class: Book
734
+ )
735
+ resource.resource_name
736
+ #=> 'books'
737
+ ```
738
+
739
+ A resource must be initialized with either a `resource_class` or a `resource_name`. It defines the following properties:
740
+
741
+ - `#collection`: A `Cuprum::Collections` collection, used to perform queries and persistence operations on the resource data.
742
+ - `#resource_class`: The `Class` of items in the resource.
743
+ - `#resource_name`: The name of the resource as a `String`. If the resource is initialized with a `resource_class`, the `resource_name` is derived from the given class.
744
+ - `#routes`: A [Cuprum::Rails::Routes](#routes) object for the resource. If not given, a default routes object is generated for the resource.
745
+ - `#singular`: If true, the resource is a singular resource (e.g. `/user`, as opposed to the plural `/books` resource). Also defines the `#singular?` and `#plural` predicates.
746
+
747
+ <a id="routes"></a>
748
+
749
+ #### Routes
750
+
751
+ Each resource has a `Cuprum::Rails::Routes` object that represents the routes implemented for the controller. The routes are typically used in responders when generating the controller response (see [Responders](#responders), below).
752
+
753
+ ```ruby
754
+ routes = Cuprum::Rails::Routes.new(base_path: '/books') do
755
+ route :published, 'published'
756
+ route :publish, ':id/publish'
757
+ end
758
+ routes.published_path
759
+ #=> '/books/published'
760
+ ```
761
+
762
+ Some routes include **wildcards**, such as the `:publish` route above which requires an `:id` wildcard; nested resources will require a wildcard value (the parent resource id) for all resourceful routes. Wildcards are assigned using the `#with_wildcards` method, which creates a copy of the routes object with the assigned wildcards.
763
+
764
+ ```ruby
765
+ routes.publish_path
766
+ #=> raises a Cuprum::Rails::Routes::MissingWildcardError exception
767
+ routes.with_wildcards(id: 0).publish_path
768
+ #=> /books/0/publish
769
+ ```
770
+
771
+ `Cuprum::Rails` defines templates for defining resourceful routes for both singular and plural resources. These define the standard CRUD operations for a resource.
772
+
773
+ ```ruby
774
+ routes = Cuprum::Rails::Routing::PluralRoutes.new(base_path: '/books')
775
+ routes = routes.with_wildcards(id: 0)
776
+ routes.create_path
777
+ #=> '/books'
778
+ routes.destroy_path
779
+ #=> '/books'
780
+ routes.edit_path
781
+ #=> '/books/0/edit'
782
+ routes.index_path
783
+ #=> '/books'
784
+ routes.new_path
785
+ #=> '/books/new'
786
+ routes.show_path
787
+ #=> '/books/0'
788
+ routes.update_path
789
+ #=> '/books/0'
790
+
791
+ routes = Cuprum::Rails::Routing::SingularRoutes.new(base_path: '/book')
792
+ routes.create_path
793
+ #=> '/book'
794
+ routes.destroy_path
795
+ #=> '/book'
796
+ routes.edit_path
797
+ #=> '/book/edit'
798
+ routes.new_path
799
+ #=> '/book/new'
800
+ routes.show_path
801
+ #=> '/book'
802
+ routes.update_path
803
+ #=> '/book'
804
+ ```
805
+
806
+ <a id="responders"></a>
807
+
808
+ ### Responders
809
+
810
+ In a `Cuprum::Rails` controller, the responder is responsible for turning the action result into a response (see [The Action Lifecycle](#controllers-action-lifecycle), above). Each request format should have a dedicated responder, e.g. an `HtmlResponder` is used to respond to HTML requests.
811
+
812
+ ```ruby
813
+ class CustomResponder < Cuprum::Rails::Responders::HtmlResponder
814
+ action :publish do
815
+ match :success do
816
+ redirect_to(resource.routes.show_path)
817
+ end
818
+
819
+ match :failure do
820
+ render 'show'
821
+ end
822
+ end
823
+
824
+ match :failure, error: Authorization::NotAuthorizedError do
825
+ redirect_to(login_path)
826
+ end
827
+
828
+ private
829
+
830
+ def login_path
831
+ '/login'
832
+ end
833
+ end
834
+ ```
835
+
836
+ First, we are using the `.action` class method to define responses for the `:publish` action. If the result is successful, it redirects to the `:show` page. If the result is failing, it instead renders the `:show` page and assigns the error (if any) to `@error`. Next, we are using the `.match` class method to define a response for a failing result with an `Authorization::NotAuthorizedError`.
837
+
838
+ A result will be matched to a response in order of specificity:
839
+
840
+ - An `.action` clause with a matching `error:` (if any).
841
+ - A generic `.match` clause with a matching `error:`.
842
+ - An `.action` clause with a matching status, either `:success` or `:failure`.
843
+ - A generic `.match` clause with a matching status.
844
+
845
+ In our case, consider a `:publish` request that fails with an `Authorization::NotAuthorizedError`. The responder will first check for a clause matching both the action and the error. It will then check for a generic action response with the error, which the `.match` clause we defined. If the request failed with a different error, the responder would not find a match for the error, and would fall back to the generic `:failure` clause for the action. Finally, if there was no `.action` clause for the action, or the clause did not specify a `:failure` clause, it would perform the generic `:failure` clause for any action.
846
+
847
+ `Cuprum::Rails` also defines the following built-in responders:
848
+
849
+ **Cuprum::Rails::Responders::HtmlResponder**
850
+
851
+ Provides default responses for HTML requests.
852
+
853
+ - For a successful result, renders the template for the action and assigns the result value as local variables.
854
+ - For a failing result, redirects to the resource `:index` page (for a collection action) or the resource `:show` page (for a member action).
855
+
856
+ **Cuprum::Rails::Responders::Html::PluralResource**
857
+
858
+ Provides some additional response handling for plural resources.
859
+
860
+ - For a failed `#create` result, renders the `:new` template.
861
+ - For a successful `#create` result, redirects to the `:show` page.
862
+ - For a successful `#destroy` result, redirects to the `:show` page.
863
+ - For a failed `#index` result, redirects to the root page.
864
+ - For a failed `#update` result, renders the `:edit` template.
865
+ - For a successful `#update` result, redirects to the `:show` page.
866
+
867
+ **Cuprum::Rails::Responders::Html::SingularResource**
868
+
869
+ Provides some additional response handling for singular resources.
870
+
871
+ - For a failed `#create` result, renders the `:new` template.
872
+ - For a successful `#create` result, redirects to the `:show` page.
873
+ - For a successful `#destroy` result, redirects to the parent resource.
874
+ - For a failed `#update` result, renders the `:edit` template.
875
+ - For a successful `#update` result, redirects to the `:show` page.
876
+
877
+ **Cuprum::Rails::Responders::JsonResponder**
878
+
879
+ Provides default responses for JSON requests.
880
+
881
+ - For a successful result, serializes the result value and generates a JSON object of the form `{ ok: true, data: serialized_value }`.
882
+ - For a failing result, creates and serializes a generic error and generates a JSON object of the form `{ ok: false, error: serialized_error }` and a status of `500 Internal Server Error`.
883
+
884
+ **Cuprum::Rails::Responders::Json::Resource**
885
+
886
+ - For a successful `#create` result, serializes the result value with a status of `201 Created`.
887
+ - For a failed result with an `AlreadyExists` error, serializes the error with a status of `422 Unprocessable Entity`.
888
+ - For a failed result with a `FailedValidation` error, serializes the error with a status of `422 Unprocessable Entity`.
889
+ - For a failed result with a `MissingParameters` error, serializes the error with a status of `400 Bad Request`.
890
+ - For a failed result with a `NotFound` error, serializes the error with a status of `404 Not Found`.
891
+
892
+ <a id="responses"></a>
893
+
894
+ #### Responses
895
+
896
+ Response objects implement the final step of [the Action Lifecycle](#controllers-action-lifecycle), and are returned when a [Responder](#responders) is `#call`ed. Each response class implements a specific type of response, such as an HTML redirect or a serialized JSON response, and encapsulates the data necessary to perform that response.
897
+
898
+ Internally, each response delegates to the `renderer`, which must be passed to the `#call` method. This delegation allows the response to abstract out the details of generating a response to the renderer. During the action lifecycle, the renderer will be the controller instance.
899
+
900
+ ```ruby
901
+ data = {
902
+ 'ok' => 'true',
903
+ 'data' => { 'book' => { 'title' => 'Gideon the Ninth' } }
904
+ }
905
+ response = Cuprum::Rails::Responses::JsonResponse.new(data: data)
906
+ renderer = instance_double(ActionController::Base, render: nil)
907
+
908
+ response.call(renderer)
909
+ expect(renderer).to have_received(:render).with(json: data)
910
+ #=> true
911
+ ```
912
+
913
+ Responses should not be generated directly; they are created as part of the action lifecycle.
914
+
915
+ `Cuprum::Rails` defines the following responses:
916
+
917
+ **Cuprum::Rails::Responses::Html::RedirectResponse**
918
+
919
+ A response for an HTML redirect. Takes the redirect `path` and an optional `:status` keyword, and calls `renderer.redirect_to`.
920
+
921
+ **Cuprum::Rails::Responses::Html::RenderResponse**
922
+
923
+ A response for an HTML rendered view. Takes the `template` to render, as well as optional keywords for the `:layout`, the `:status`, and the `:assigns` to assign as local variables. Calls `renderer.render`.
924
+
925
+ **Cuprum::Rails::Responses::JsonResponse**
926
+
927
+ A response for a JSON request. Takes the serialized `:data` to return as well as an optional `:status` keyword. Calls `renderer.render` with the `json:` option.
928
+
929
+ <a id="serializers"></a>
930
+
931
+ ### Serializers
932
+
933
+ Serializers convert entities and data structures into serialized data. Each serializer is specific to one format and one type of object - for example, the `Cuprum::Rails::Serializers::Json::ErrorSerializer` generates a JSON representation of a `Cuprum::Error`.
934
+
935
+ Serializers are also recursive: the `#call` method must accept a `:serializers` keyword, which contains the serializer mappings for the current controller or context. This allows serialization to be context-specific - one controller may use one serializer for a particular record class, while another controller may use a limited set of attributes, such as an admin versus a user-facing controller.
936
+
937
+ ```ruby
938
+ class StructSerializer < Cuprum::Rails::Serializers::JsonSerializer
939
+ def call(struct, serializers:)
940
+ struct.each_pair.with_object do |(key, value), hsh|
941
+ hsh[key] = super(value, serializers: serializers)
942
+ end
943
+ end
944
+ end
945
+
946
+ serializer = StructSerializer.new
947
+ struct =
948
+ Struct
949
+ .new(:series, :author, :titles)
950
+ .new('The Locked Tomb', 'Tamsyn Muir', ['Gideon the Ninth', 'Harrow the Ninth'])
951
+ serializer.call(struct, serializers: Cuprum::Rails::Serializers::Json.default_serializers)
952
+ #=> {
953
+ # 'series' => 'The Locked Tomb',
954
+ # 'author' => 'Tamsyn Muir',
955
+ # 'titles' => ['Gideon the Ninth', 'Harrow the Ninth']
956
+ # }
957
+ ```
958
+
959
+ Above, we define a custom serializer for serializing `Struct` instances. We then use the serializer on our Book-like struct by passing it to the `#call` method, along with the default JSON serializers. The `#call` method takes each pair of keys and values and calls `super()`, which finds the configured serializer for each value. In our case, the default serializer for a `String` returns the string, while the default serializer for an `Array` returns a new array whose items are the serialized array items. Finally, a `Hash` with `String` keys is generated, which is our `Struct` serialized into a JSON-compatible object.
960
+
961
+ `Cuprum::Rails` defines the following serializers:
962
+
963
+ **Cuprum::Rails::Serializers::Json::Serializer**
964
+
965
+ The base class for JSON serializers. Takes a configured `serializers:` hash and finds the serializer for the given object, then calls that serializer with the object and the configured serializers.
966
+
967
+ The serializer for an object is determined based on the object's class. Specifically, for each ancestor of the object's class, the configured serializers are checked for a key matching that ancestor. If that class or module is a key in the configured hash, then the corresponding serializer is used to serialize the object. If the configured serializers do not include a serializer for any of the object class's ancestors, raises an `UndefinedSerializerError`.
968
+
969
+ **Cuprum::Rails::Serializers::Json::AttributesSerializer**
970
+
971
+ Serializes an object by finding and calling the configured serializer (see above) for each attribute defined for the serializer. See [Attribute Serializers](#attribute-serializers) below.
972
+
973
+ **Cuprum::Rails::Serializers::Json::ActiveRecordSerializer**
974
+
975
+ Serializes an `ActiveRecord` model by delegating to the `#as_json` method. An alternative to defining a specific `AttributeSerializer` (see above) for each model class.
976
+
977
+ **Cuprum::Rails::Serializers::Json::ArraySerializer**
978
+
979
+ Serializes an `Array` by finding and calling the configured serializer for each array item (see above). This is the default serializer for `Array`s.
980
+
981
+ **Cuprum::Rails::Serializers::Json::ErrorSerializer**
982
+
983
+ Serializes a `Cuprum::Error` by delegating to the `#as_json` method. This is the default serializer for errors.
984
+
985
+ **Cuprum::Rails::Serializers::Json::HashSerializer**
986
+
987
+ Serializes a `Hash` with `String` keys by finding and calling the configured serializer for each hash value (see above). This is the default serializer for `Hash`es.
988
+
989
+ **Cuprum::Rails::Serializers::Json::IdentitySerializer**
990
+
991
+ Serializes a value object by returning the object. This is the default serializer for `nil`, `true`, `false`, `Integer`s, `Float`s, and `String`s.
992
+
993
+ <a id="attribute-serializers"></a>
994
+
995
+ #### Attribute Serializers
996
+
997
+ Attribute serializers define a set of attributes to be serialized. This is useful for whitelisting a specific set of attributes to return in the serialized object.
998
+
999
+ ```ruby
1000
+ class RecordSerializer < Cuprum::Rails::Serializers::Json::AttributesSerializer
1001
+ attribute :id
1002
+ end
1003
+
1004
+ class BookSerializer < RecordSerializer
1005
+ attribute :title
1006
+ attribute :author
1007
+ attribute :series
1008
+ end
1009
+
1010
+ class DetailedBookSerializer < BookSerializer
1011
+ attribute :category
1012
+ attribute :published_at
1013
+ end
1014
+
1015
+ serializers = Cuprum::Rails::Serializers::Json.default_serializers
1016
+ book = Book.new(
1017
+ id: 0,
1018
+ title: 'Nona The Ninth',
1019
+ author: 'Tamsyn Muir',
1020
+ series: 'The Locked Tomb',
1021
+ category: 'Science Fiction and Fantasy',
1022
+ )
1023
+
1024
+ BookSerializer.new.call(book, serializers: serializers)
1025
+ #=> {
1026
+ # 'id' => 0,
1027
+ # 'title' => 'Nona The Ninth',
1028
+ # 'author' => 'Tamsyn Muir',
1029
+ # 'series' => 'The Locked Tombs'
1030
+ # }
1031
+
1032
+ DetailedBookSerializer.new.call(book, serializers: serializers)
1033
+ #=> {
1034
+ # 'id' => 0,
1035
+ # 'title' => 'Nona The Ninth',
1036
+ # 'author' => 'Tamsyn Muir',
1037
+ # 'series' => 'The Locked Tombs',
1038
+ # 'category' => 'Science Fiction and Fantasy',
1039
+ # 'published_at' => nil
1040
+ # }
1041
+ ```
1042
+
1043
+ Above, we define an abstract `RecordSerializer` and a `BookSerializer`, which inherits the `:id` attribute and defines the `:title`, `:author`, and `:series` attributes. When the book serializer is called, it serializes the values of each attribute using the configured serializers; any attributes that are not defined on the serializer are ignored.
1044
+
1045
+ We also define a `DetailedBookSerializer` which inherits from `BookSerializer`. This allows us to reuse the attributes defined for our basic book serializer.