cuprum-rails 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +145 -0
  3. data/DEVELOPMENT.md +20 -0
  4. data/README.md +356 -63
  5. data/lib/cuprum/rails/action.rb +32 -16
  6. data/lib/cuprum/rails/actions/create.rb +62 -15
  7. data/lib/cuprum/rails/actions/destroy.rb +23 -7
  8. data/lib/cuprum/rails/actions/edit.rb +23 -7
  9. data/lib/cuprum/rails/actions/index.rb +30 -10
  10. data/lib/cuprum/rails/actions/middleware/associations/cache.rb +112 -0
  11. data/lib/cuprum/rails/actions/middleware/associations/find.rb +23 -0
  12. data/lib/cuprum/rails/actions/middleware/associations/parent.rb +70 -0
  13. data/lib/cuprum/rails/actions/middleware/associations/query.rb +140 -0
  14. data/lib/cuprum/rails/actions/middleware/associations.rb +12 -0
  15. data/lib/cuprum/rails/actions/middleware/log_request.rb +126 -0
  16. data/lib/cuprum/rails/actions/middleware/log_result.rb +51 -0
  17. data/lib/cuprum/rails/actions/middleware/resources/find.rb +44 -0
  18. data/lib/cuprum/rails/actions/middleware/resources/query.rb +91 -0
  19. data/lib/cuprum/rails/actions/middleware/resources.rb +11 -0
  20. data/lib/cuprum/rails/actions/middleware.rb +13 -0
  21. data/lib/cuprum/rails/actions/new.rb +16 -4
  22. data/lib/cuprum/rails/actions/parameter_validation.rb +60 -0
  23. data/lib/cuprum/rails/actions/resource_action.rb +119 -42
  24. data/lib/cuprum/rails/actions/show.rb +23 -7
  25. data/lib/cuprum/rails/actions/update.rb +70 -22
  26. data/lib/cuprum/rails/actions.rb +11 -7
  27. data/lib/cuprum/rails/collection.rb +27 -47
  28. data/lib/cuprum/rails/command.rb +3 -1
  29. data/lib/cuprum/rails/commands/destroy_one.rb +10 -6
  30. data/lib/cuprum/rails/commands/find_many.rb +8 -1
  31. data/lib/cuprum/rails/commands/find_matching.rb +1 -1
  32. data/lib/cuprum/rails/commands/find_one.rb +8 -0
  33. data/lib/cuprum/rails/commands/insert_one.rb +17 -6
  34. data/lib/cuprum/rails/commands/update_one.rb +16 -5
  35. data/lib/cuprum/rails/constraints/parameters_contract.rb +14 -0
  36. data/lib/cuprum/rails/constraints.rb +10 -0
  37. data/lib/cuprum/rails/controller.rb +12 -2
  38. data/lib/cuprum/rails/controllers/action.rb +100 -0
  39. data/lib/cuprum/rails/controllers/class_methods/actions.rb +33 -7
  40. data/lib/cuprum/rails/controllers/class_methods/configuration.rb +36 -0
  41. data/lib/cuprum/rails/controllers/class_methods/middleware.rb +88 -0
  42. data/lib/cuprum/rails/controllers/class_methods/validations.rb +2 -2
  43. data/lib/cuprum/rails/controllers/configuration.rb +41 -1
  44. data/lib/cuprum/rails/controllers/middleware.rb +59 -0
  45. data/lib/cuprum/rails/controllers.rb +2 -0
  46. data/lib/cuprum/rails/errors/invalid_parameters.rb +55 -0
  47. data/lib/cuprum/rails/errors/invalid_statement.rb +11 -0
  48. data/lib/cuprum/rails/errors/missing_parameter.rb +42 -0
  49. data/lib/cuprum/rails/errors/resource_error.rb +46 -0
  50. data/lib/cuprum/rails/errors.rb +6 -1
  51. data/lib/cuprum/rails/map_errors.rb +29 -1
  52. data/lib/cuprum/rails/query.rb +1 -1
  53. data/lib/cuprum/rails/repository.rb +12 -25
  54. data/lib/cuprum/rails/request.rb +149 -60
  55. data/lib/cuprum/rails/resource.rb +119 -85
  56. data/lib/cuprum/rails/responders/base_responder.rb +78 -0
  57. data/lib/cuprum/rails/responders/html/plural_resource.rb +9 -39
  58. data/lib/cuprum/rails/responders/html/rendering.rb +81 -0
  59. data/lib/cuprum/rails/responders/html/resource.rb +107 -0
  60. data/lib/cuprum/rails/responders/html/singular_resource.rb +9 -38
  61. data/lib/cuprum/rails/responders/html.rb +2 -0
  62. data/lib/cuprum/rails/responders/html_responder.rb +8 -52
  63. data/lib/cuprum/rails/responders/json/resource.rb +3 -3
  64. data/lib/cuprum/rails/responders/json_responder.rb +31 -16
  65. data/lib/cuprum/rails/responders/matching.rb +29 -27
  66. data/lib/cuprum/rails/responders/serialization.rb +11 -9
  67. data/lib/cuprum/rails/responders.rb +1 -0
  68. data/lib/cuprum/rails/responses/head_response.rb +24 -0
  69. data/lib/cuprum/rails/responses/html/redirect_back_response.rb +55 -0
  70. data/lib/cuprum/rails/responses/html/redirect_response.rb +19 -4
  71. data/lib/cuprum/rails/responses/html/render_response.rb +17 -5
  72. data/lib/cuprum/rails/responses/html.rb +6 -2
  73. data/lib/cuprum/rails/responses.rb +1 -0
  74. data/lib/cuprum/rails/result.rb +36 -0
  75. data/lib/cuprum/rails/routes.rb +36 -23
  76. data/lib/cuprum/rails/rspec/contract_helpers.rb +57 -0
  77. data/lib/cuprum/rails/rspec/contracts/action_contracts.rb +754 -0
  78. data/lib/cuprum/rails/rspec/contracts/actions/create_contracts.rb +289 -0
  79. data/lib/cuprum/rails/rspec/contracts/actions/destroy_contracts.rb +164 -0
  80. data/lib/cuprum/rails/rspec/contracts/actions/edit_contracts.rb +73 -0
  81. data/lib/cuprum/rails/rspec/contracts/actions/index_contracts.rb +108 -0
  82. data/lib/cuprum/rails/rspec/contracts/actions/new_contracts.rb +111 -0
  83. data/lib/cuprum/rails/rspec/contracts/actions/show_contracts.rb +72 -0
  84. data/lib/cuprum/rails/rspec/contracts/actions/update_contracts.rb +263 -0
  85. data/lib/cuprum/rails/rspec/contracts/actions.rb +8 -0
  86. data/lib/cuprum/rails/rspec/contracts/command_contracts.rb +479 -0
  87. data/lib/cuprum/rails/rspec/contracts/responder_contracts.rb +232 -0
  88. data/lib/cuprum/rails/rspec/contracts/routes_contracts.rb +363 -0
  89. data/lib/cuprum/rails/rspec/contracts/serializers_contracts.rb +70 -0
  90. data/lib/cuprum/rails/rspec/contracts.rb +8 -0
  91. data/lib/cuprum/rails/rspec/matchers/be_a_result_matcher.rb +64 -0
  92. data/lib/cuprum/rails/rspec/matchers.rb +41 -0
  93. data/lib/cuprum/rails/serializers/base_serializer.rb +60 -0
  94. data/lib/cuprum/rails/serializers/context.rb +84 -0
  95. data/lib/cuprum/rails/serializers/json/active_record_serializer.rb +2 -2
  96. data/lib/cuprum/rails/serializers/json/array_serializer.rb +9 -8
  97. data/lib/cuprum/rails/serializers/json/attributes_serializer.rb +95 -172
  98. data/lib/cuprum/rails/serializers/json/error_serializer.rb +2 -2
  99. data/lib/cuprum/rails/serializers/json/hash_serializer.rb +9 -8
  100. data/lib/cuprum/rails/serializers/json/identity_serializer.rb +3 -3
  101. data/lib/cuprum/rails/serializers/json/properties_serializer.rb +252 -0
  102. data/lib/cuprum/rails/serializers/json.rb +2 -1
  103. data/lib/cuprum/rails/serializers.rb +3 -1
  104. data/lib/cuprum/rails/version.rb +1 -1
  105. data/lib/cuprum/rails.rb +19 -16
  106. metadata +73 -131
  107. data/lib/cuprum/rails/controller_action.rb +0 -121
  108. data/lib/cuprum/rails/errors/missing_parameters.rb +0 -33
  109. data/lib/cuprum/rails/errors/missing_primary_key.rb +0 -46
  110. data/lib/cuprum/rails/errors/undefined_permitted_attributes.rb +0 -34
  111. data/lib/cuprum/rails/rspec/command_contract.rb +0 -460
  112. data/lib/cuprum/rails/rspec/define_route_contract.rb +0 -84
  113. data/lib/cuprum/rails/serializers/json/serializer.rb +0 -66
data/README.md CHANGED
@@ -8,6 +8,7 @@ Cuprum::Rails defines the following objects:
8
8
  - [Commands](#commands): Each collection is comprised of `Cuprum` commands, which implement common collection operations such as inserting or querying data.
9
9
  - [Controllers](#controllers): Decouples controller responsibilities for precise control, reusability, and reduction of boilerplate code.
10
10
  - [Actions](#actions): Implement a controller's actions as a `Cuprum` command.
11
+ - [Middleware](#middleware): Wraps a controller's actions with additional functionality.
11
12
  - [Requests](#requests): Encapsulates a controller request.
12
13
  - [Resources](#resources) and [Routes](#routes): Configuration for a resourceful controller.
13
14
  - [Responders](#responders) and [Responses](#responses): Generate controller responses from action results.
@@ -31,7 +32,7 @@ The second benefit is *reusability*. Breaking down a controller into its constit
31
32
 
32
33
  ### Compatibility
33
34
 
34
- Cuprum::Collections is tested against Ruby (MRI) 2.6 through 3.0.
35
+ Cuprum::Rails is tested against Ruby (MRI) 3.0 through 3.2, and Rails 6.1 through 7.1.
35
36
 
36
37
  ### Documentation
37
38
 
@@ -39,7 +40,7 @@ Documentation is generated using [YARD](https://yardoc.org/), and can be generat
39
40
 
40
41
  ### License
41
42
 
42
- Copyright (c) 2021 Rob Smith
43
+ Copyright (c) 2021-2022 Rob Smith
43
44
 
44
45
  Stannum is released under the [MIT License](https://opensource.org/licenses/MIT).
45
46
 
@@ -59,7 +60,7 @@ Please note that the `Cuprum::Collections` project is released with a [Contribut
59
60
 
60
61
  ## Reference
61
62
 
62
- <a id="collections"></a>
63
+ <span id="collections"></span>
63
64
 
64
65
  ### Collections
65
66
 
@@ -110,6 +111,7 @@ Initializing a collection requires the `:record_class` keyword, which should be
110
111
  - 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
112
  - The `:primary_key_name` parameter specifies the attribute that serves as the primary key for the collection entities. The default value is `:id`.
112
113
  - The `:primary_key_type` parameter specifies the type of the primary key attribute. The default value is `Integer`.
114
+ - The `:qualified_name` parameter acts as a unique identifier for the collection. It is used as the unique key in repositories.
113
115
 
114
116
  <a id="commands"></a>
115
117
 
@@ -388,20 +390,39 @@ require 'cuprum/rails/repository'
388
390
  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
391
 
390
392
  ```ruby
391
- repository = Cuprum::Collections::Repository.new
393
+ repository = Cuprum::Rails::Repository.new
392
394
  repository.key?('books')
393
395
  #=> false
394
396
 
395
- repository.add(books_collection)
396
-
397
- repository.key?('books')
398
- #=> true
399
- repository.keys
400
- #=> ['books']
397
+ collection = repository.find_or_create(entity_class: Book)
398
+ #=> a Cuprum::Rails::Collection
399
+ collection.collection_name
400
+ #=> 'books'
401
+ collection.qualified_name
402
+ #=> 'books'
401
403
  repository['books']
402
404
  #=> the books collection
403
405
  ```
404
406
 
407
+ If the model has a namespace, e.g. `Authentication::User`, the `#collection_name` will be based on the last name segment, while the `#qualified_name` will be based on the entire name.
408
+
409
+ ```ruby
410
+ repository = Cuprum::Rails::Repository.new
411
+ repository.key?('authentication/users')
412
+ #=> false
413
+
414
+ collection = repository.find_or_create(entity_class: Authentication::User)
415
+ #=> a Cuprum::Rails::Collection
416
+ collection.collection_name
417
+ #=> 'users'
418
+ collection.qualified_name
419
+ #=> 'authentication/users'
420
+ repository['authentication/users']
421
+ #=> the users collection
422
+ ```
423
+
424
+ You can also pass the `#collection_name` and `#qualified_name` as parameters.
425
+
405
426
  <a id="controllers"></a>
406
427
 
407
428
  ### Controllers
@@ -439,7 +460,7 @@ class BooksController
439
460
  )
440
461
  end
441
462
 
442
- responder :html, Cuprum::Rails::Responders::Html::PluralResource
463
+ responder :html, Cuprum::Rails::Responders::Html::Resource
443
464
  responder :json, Cuprum::Rails::Responders::Json::Resource
444
465
 
445
466
  action :create, Cuprum::Rails::Actions::Create
@@ -464,6 +485,14 @@ The [Responders](#responders) determine what request formats are accepted by the
464
485
 
465
486
  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
487
 
488
+ You can also define `.default_format`, which sets a default value for when the request does not specify a format. For example, a request to `/api/books.html` specifies the `:html` format, while there is no format specified for `/api/books`.
489
+
490
+ ```ruby
491
+ class BooksController
492
+ default_format :html
493
+ end
494
+ ```
495
+
467
496
  #### Defining Actions
468
497
 
469
498
  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`.
@@ -484,6 +513,47 @@ class BooksController
484
513
  end
485
514
  ```
486
515
 
516
+ <a id="controllers-defining-middleware"></a>
517
+
518
+ #### Defining Middleware
519
+
520
+ You can use [middleware](#middleware) to insert functionality before, after, or around controller actions. Think of it as a supercharged alternative to the traditional Rails `before_action` and `after_action` hooks, but without the magic behavior. Use cases for middleware include:
521
+
522
+ - Authentication
523
+ - Logging
524
+ - Profiling
525
+
526
+ Middleware commands have a specific interface. See [Middleware](#middleware), below, for how to define your own middleware commands.
527
+
528
+ ```ruby
529
+ class BooksController
530
+ middleware LoggingMiddleware
531
+ middleware AuthenticationMiddleware, except: %i[index show]
532
+ middleware ProfilingMiddleware, only: %i[create update]
533
+ end
534
+ ```
535
+
536
+ Adding middleware to a controller is straightforward. In our example above, the `LoggingMiddleware` will run for all actions, the `AuthenticationMiddleware` will run for all actions except for `:index` and `:show`, and the `ProfilingMiddleware` will run for the `:create` and `:update` actions.
537
+
538
+ Each middleware command can have functionality that runs before, after, or around the action (and subsequent middleware). Code that runs before the action has access to the `request:`, and can modify the request passed to the next command or even skip the action and return its own result. Code that runs after the action has access to the `request:` and the action `result`, and can modify or replace the result.
539
+
540
+ The middleware is executed in the order it is defined. For the `BooksController#create` action, the code would run as follows:
541
+
542
+ 1. `LoggingMiddleware`: Any code that executes before the action.
543
+ 1. `AuthenticationMiddleware`: Any code that executes before the action.
544
+ 1. `ProfilingMiddleware`: Any code that executes before the action.
545
+ 1. `Books::CreateAction`
546
+ 1. `ProfilingMiddleware`: Any code that executes after the action.
547
+ 1. `AuthenticationMiddleware`: Any code that executes after the action.
548
+ 1. `LoggingMiddleware`: Any code that executes after the action.
549
+
550
+ Code that runs before or around the action can skip the action and return its own result. For example, the `AuthenticationMiddleware` will check for a valid session. If there is not a valid session, it will return a failing result rather than calling the action. In this case, the code would run as follows:
551
+
552
+ 1. `LoggingMiddleware`: Any code that executes before the action.
553
+ 1. `AuthenticationMiddleware`: The session is not found, so the action is not called.
554
+ 1. `AuthenticationMiddleware`: Any code that executes after the action.
555
+ 1. `LoggingMiddleware`: Any code that executes after the action.
556
+
487
557
  <a id="controllers-action-lifecycle"></a>
488
558
 
489
559
  #### The Action Lifecycle
@@ -492,10 +562,11 @@ Inside a controller action, `Cuprum::Rails` splits up the responsibilities of re
492
562
 
493
563
  1. The Action
494
564
  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`.
565
+ 2. The `action` is wrapped with any `middleware` that is defined by the controller for that action.
566
+ 3. The controller `#request` is wrapped in a `Cuprum::Rails::Request`, which is passed to the `action`'s `#call` method, returning the `result`.
496
567
  2. The Responder
497
568
  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`.
569
+ 2. The `responder_class` is initialized with the `action_name`, `controller_name`, `resource`, and `serializers`, returning the `responder`.
499
570
  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
571
  3. The Response
501
572
  1. The `response` is then called with the controller, which allows it to reference native Rails controller methods for rendering or redirecting.
@@ -506,15 +577,15 @@ Let's walk through this step by step. We start by making a `POST` request to `/b
506
577
  1. We initialize our configured action class, which is `Cuprum::Rails::Actions::Index`.
507
578
  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
579
  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`.
580
+ 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::Resource`, which defines default behavior for responding to resourceful requests.
581
+ 2. Our `Responders::Html::Resource` is initialized, giving us a `responder`.
511
582
  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
583
  3. The Response
513
584
  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
585
 
515
586
  <a id="actions"></a>
516
587
 
517
- ### Actions
588
+ ### Controller Actions
518
589
 
519
590
  ```ruby
520
591
  require 'cuprum/rails/action'
@@ -547,8 +618,11 @@ class PublishBook < Cuprum::Rails::Actions::ResourceAction
547
618
  private
548
619
 
549
620
  def process(request)
550
- book_id = step { resource_id }
551
- book = step { collection.find_one.call(entity_id: book_id) }
621
+ super
622
+
623
+ step { require_resource_id }
624
+
625
+ book = step { collection.find_one.call(primary_key: resource_id) }
552
626
 
553
627
  book.published_at = DateTime.current
554
628
 
@@ -561,12 +635,57 @@ end
561
635
 
562
636
  `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
637
 
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.
638
+ - `#resource_id`: Returns `params[:id]`.
639
+ - `#resource_params`: Filters `params[singular_resource_name]` and using `resource.permitted_attributes`.
640
+
641
+ #### Transactions
642
+
643
+ `Cuprum::Rails` integrates with `ActiveRecord` to support database transactions. The `#transaction` method integrates native transactions with the `Cuprum` control flow:
644
+
645
+ ```ruby
646
+ class ReturnBook < Cuprum::Rails::Actions::ResourceAction
647
+ private
648
+
649
+ def books_collection
650
+ @books_collection ||= repository['books']
651
+ end
652
+
653
+ def process(request)
654
+ super
655
+
656
+ step { require_resource_id }
657
+
658
+ loan = step { collection.find_one.call(primary_key: resource_id) }
659
+
660
+ transaction do
661
+ step { return_book(loan.book_id) }
662
+
663
+ step { collection.destroy_one.call(entity: loan) }
664
+ end
665
+ end
666
+
667
+ def return_book(book_id)
668
+ step do
669
+ books_collection.assign_one.call(
670
+ attributes: { 'borrowed' => false },
671
+ entity: book
672
+ )
673
+ end
674
+
675
+ books_collection.update_one.call(entity: book)
676
+ end
677
+ end
678
+ ```
679
+
680
+ Here, we are defining a custom action for returning a borrowed library book. Inside our transaction, we are defining two steps. First, we are marking the book as no longer borrowed, so other patrons will be able to check it out or request it. Second, we destroy the join model between the user and the book. If either of these steps returns a failing result, the transaction will automatically roll back.
681
+
682
+ If you do not want to roll back on a failed step, use the native `ActiveRecord.transaction` method instead.
683
+
684
+ #### Actions
566
685
 
567
686
  `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
687
 
569
- #### Create
688
+ ##### Create
570
689
 
571
690
  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
691
 
@@ -583,13 +702,11 @@ Book.where(title: 'Gideon the Ninth').exist?
583
702
  #=> true
584
703
  ```
585
704
 
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.
705
+ If the params do not include attributes for the resource, the action returns a failing result with a `Cuprum::Rails::Errors::InvalidParameters` error.
589
706
 
590
- If the permitted attributes are not defined for the resource, the action returns a failing result with a `Cuprum::Rails::Errors::UndefinedPermittedAttributes` error.
707
+ If the created record is not valid, the action returns a failing result with a `Cuprum::Collections::Errors::FailedValidation` error.
591
708
 
592
- #### Destroy
709
+ ##### Destroy
593
710
 
594
711
  The `Destroy` action removes the record from the collection via `collection.destroy_one`. The action returns a Hash containing the deleted record.
595
712
 
@@ -606,9 +723,11 @@ Book.where(id: 0).exist?
606
723
  #=> false
607
724
  ```
608
725
 
726
+ If the params do not include a primary key for the resource, the action returns a failing result with a `Cuprum::Rails::Errors::InvalidParameters` error.
727
+
609
728
  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
729
 
611
- #### Edit
730
+ ##### Edit
612
731
 
613
732
  The `Edit` action finds the record with the given primary key via `collection.find_one` and returns a Hash containing the found record.
614
733
 
@@ -622,9 +741,11 @@ result.value
622
741
  #=> { 'book' => #<Book id: 0> }
623
742
  ```
624
743
 
744
+ If the params do not include a primary key for the resource, the action returns a failing result with a `Cuprum::Rails::Errors::InvalidParameters` error.
745
+
625
746
  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
747
 
627
- #### Index
748
+ ##### Index
628
749
 
629
750
  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
751
 
@@ -642,7 +763,7 @@ result.value
642
763
  #=> { 'books' => [#<Book>, #<Book>, #<Book>] }
643
764
  ```
644
765
 
645
- #### New
766
+ ##### New
646
767
 
647
768
  The `New` action builds a new record with empty attributes using `collection.build_one`, and returns a Hash containing the new record.
648
769
 
@@ -655,7 +776,7 @@ result.value
655
776
  #=> { 'book' => #<Book> }
656
777
  ```
657
778
 
658
- #### Show
779
+ ##### Show
659
780
 
660
781
  The `Show` action finds the record with the given primary key via `collection.find_one` and returns a Hash containing the found record.
661
782
 
@@ -669,9 +790,11 @@ result.value
669
790
  #=> { 'book' => #<Book id: 0> }
670
791
  ```
671
792
 
793
+ If the params do not include a primary key for the resource, the action returns a failing result with a `Cuprum::Rails::Errors::InvalidParameters` error.
794
+
672
795
  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
796
 
674
- #### Update
797
+ ##### Update
675
798
 
676
799
  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
800
 
@@ -688,13 +811,120 @@ Book.find(0).title
688
811
  #=> 'Gideon the Ninth'
689
812
  ```
690
813
 
814
+ If the params do not include a primary key and attributes for the resource, the action returns a failing result with a `Cuprum::Rails::Errors::InvalidParameters` error.
815
+
691
816
  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
817
 
693
818
  If the updated record is not valid, the action returns a failing result with a `Cuprum::Collections::Errors::FailedValidation` error.
694
819
 
695
- If the params do not include attributes for the resource, the action returns a failing result with a `Cuprum::Rails::Errors::MissingParameters` error.
820
+ <a id="middleware"></a>
821
+
822
+ ### Middleware
823
+
824
+ A middleware command takes two parameters. First, a `next_command` argument, which is the next item in the middleware chain (or the controller action if the middleware is the last one in the chain). Second, a `request:` keyword - this is the [request](#requests) passed down from the controller.
825
+
826
+ See [Defining Middleware](#controllers-defining-middleware), above, for using middleware in a `Cuprum::Rails::Controller`, or see [Cuprum](github.com/sleepingkingstudios/cuprum) for more information on middleware.
696
827
 
697
- If the permitted attributes are not defined for the resource, the action returns a failing result with a `Cuprum::Rails::Errors::UndefinedPermittedAttributes` error.
828
+ #### Creating Middleware
829
+
830
+ Each middleware class should be a subclass of `Cuprum::Command` and include `Cuprum::Middleware`. The constructor can optionally take either `:repository` and `:resource` keywords; if these are defined, they are passed the relevant controller property when the middleware is initialized.
831
+
832
+ ```ruby
833
+ class ExampleMiddleware < Cuprum::Command
834
+ include Cuprum::Middleware
835
+
836
+ def initialize(repository:, resource:)
837
+ super()
838
+
839
+ @repository = repository
840
+ @resource = resource
841
+ end
842
+ end
843
+ ```
844
+
845
+ #### Before An Action
846
+
847
+ Middleware commands can run before an action, similar to a native Rails `before_action` filter.
848
+
849
+ ```ruby
850
+ class AuthenticationMiddleware < Cuprum::Command
851
+ include Cuprum::Middleware
852
+
853
+ private def process(next_command, request:)
854
+ step { Authentication::RequireUser.call(request: request) }
855
+
856
+ super
857
+ end
858
+ end
859
+ ```
860
+
861
+ Here, we are creating a basic middleware command. We call our authentication command in a `step`, meaning that if the authentication command returns a failing result, we will immediately return that result. This means that our action will not run if the session is invalid.
862
+
863
+ If the authentication command returns a passing result, we call `super` to invoke the default behavior of `Cuprum::Middleware`. This calls `next_command.call(request: request)` to continue the middleware or invoke the action.
864
+
865
+ #### After An Action
866
+
867
+ Likewise, middleware commands can run after an action, similar to a native Rails `after_action` filter.
868
+
869
+ ```ruby
870
+ class LoggingMiddleware < Cuprum::Command
871
+ include Cuprum::Middleware
872
+
873
+ private def process(next_command, request:)
874
+ result = next_command.call(request: request)
875
+
876
+ if result.success?
877
+ Rails.logger.info(
878
+ "Successful Request: controller: #{request.controller_name}, action:" \
879
+ " #{request.action_name}"
880
+ )
881
+ else
882
+ Rails.logger.error(
883
+ "Failed Request: controller: #{request.controller_name}, action:" \
884
+ " #{request.action_name}, error: #{result.error.as_json}"
885
+ )
886
+ end
887
+
888
+ result
889
+ end
890
+ end
891
+ ```
892
+
893
+ This middleware is a little more complicated. Instead of intercepting the request before the action, here we are taking the result of the action and implementing some custom behavior based on the success or failure of the action. Finally, make sure to return the result.
894
+
895
+ Note that we are explicitly calling `next_command.call(request: request)` rather than relying on `super`. This is because `super` calls the next command inside a `step`, and will immediately return a failing result rather than continuing through `#process`. For our logging middleware, however, we actually want to handle both passing and failing results.
896
+
897
+ #### Around An Action
898
+
899
+ Finally, we can run middleware around an action, similiar to a native Rails `around_action` filter.
900
+
901
+ ```ruby
902
+ class ProfilingMiddleware < Cuprum::Command
903
+ include Cuprum::Middleware
904
+
905
+ private
906
+
907
+ def process(next_command, request:)
908
+ start_time = Time.current
909
+
910
+ value = super(next_command, request: request)
911
+
912
+ return if value.nil?
913
+
914
+ end_time = Time.current
915
+
916
+ value.merge('time_elapsed' => time_elapsed(start_time, end_time))
917
+ end
918
+
919
+ def time_elapsed(start_time, end_time)
920
+ difference = ((end_time - start_time).round(3) * 1_000).to_i
921
+
922
+ "#{difference} milliseconds"
923
+ end
924
+ end
925
+ ```
926
+
927
+ We start by capturing the current time, before the action is run. We then call the action via `super`; this means that the middleware will return immediately on a failed result. Once the action has run, we calculate how long the action took to run and merge that into the result value. In a production environment, we would probably pass that data to a monitoring service.
698
928
 
699
929
  <a id="requests"></a>
700
930
 
@@ -715,8 +945,11 @@ Each request defines the following properties:
715
945
  - `#method`: The HTTP method used for the request as a `Symbol`, e.g. `:get` or `:post`.
716
946
  - `#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
947
  - `#path`: The relative path of the request, including query params.
948
+ - `#path_parameters`: (also `#path_params`) The path parameters for the request, minus the Rails-provided `action` and `controller` params. A `Hash` with `String` keys.
718
949
  - `#query_parameters`: (also `#query_params`) The query parameters for the request. A `Hash` with `String` keys.
719
950
 
951
+ The request properties can also be accessed via the `#[]` method (using either String or Symbol keys), or updated via the `#[]=` method. The `#properties` method returns all of the request properties as a `Hash`.
952
+
720
953
  <a id="resources"></a>
721
954
 
722
955
  ### Resources
@@ -738,7 +971,8 @@ resource.resource_name
738
971
 
739
972
  A resource must be initialized with either a `resource_class` or a `resource_name`. It defines the following properties:
740
973
 
741
- - `#collection`: A `Cuprum::Collections` collection, used to perform queries and persistence operations on the resource data.
974
+ - `#base_url`: The base url for the collection, used when generating routes.
975
+ - `#collection`: A `Cuprum::Collections` collection, used to perform queries and persistence operations on the resource data. If not given and the collection has a `#resource_class`, then a `Cuprum::Rails::Collection` is automatically generated.
742
976
  - `#resource_class`: The `Class` of items in the resource.
743
977
  - `#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
978
  - `#routes`: A [Cuprum::Rails::Routes](#routes) object for the resource. If not given, a default routes object is generated for the resource.
@@ -853,9 +1087,11 @@ Provides default responses for HTML requests.
853
1087
  - For a successful result, renders the template for the action and assigns the result value as local variables.
854
1088
  - For a failing result, redirects to the resource `:index` page (for a collection action) or the resource `:show` page (for a member action).
855
1089
 
856
- **Cuprum::Rails::Responders::Html::PluralResource**
1090
+ **Cuprum::Rails::Responders::Html::Resource**
857
1091
 
858
- Provides some additional response handling for plural resources.
1092
+ Provides some additional response handling for resources.
1093
+
1094
+ If the resource is plural:
859
1095
 
860
1096
  - For a failed `#create` result, renders the `:new` template.
861
1097
  - For a successful `#create` result, redirects to the `:show` page.
@@ -864,9 +1100,7 @@ Provides some additional response handling for plural resources.
864
1100
  - For a failed `#update` result, renders the `:edit` template.
865
1101
  - For a successful `#update` result, redirects to the `:show` page.
866
1102
 
867
- **Cuprum::Rails::Responders::Html::SingularResource**
868
-
869
- Provides some additional response handling for singular resources.
1103
+ If the resource is singular:
870
1104
 
871
1105
  - For a failed `#create` result, renders the `:new` template.
872
1106
  - For a successful `#create` result, redirects to the `:show` page.
@@ -879,14 +1113,14 @@ Provides some additional response handling for singular resources.
879
1113
  Provides default responses for JSON requests.
880
1114
 
881
1115
  - 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`.
1116
+ - 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`. If the Rails environment is `:development`, it will instead serialize the error from the result.
883
1117
 
884
1118
  **Cuprum::Rails::Responders::Json::Resource**
885
1119
 
886
1120
  - For a successful `#create` result, serializes the result value with a status of `201 Created`.
887
1121
  - For a failed result with an `AlreadyExists` error, serializes the error with a status of `422 Unprocessable Entity`.
888
1122
  - 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`.
1123
+ - For a failed result with an `InvalidParameters` error, serializes the error with a status of `400 Bad Request`.
890
1124
  - For a failed result with a `NotFound` error, serializes the error with a status of `404 Not Found`.
891
1125
 
892
1126
  <a id="responses"></a>
@@ -932,23 +1166,26 @@ A response for a JSON request. Takes the serialized `:data` to return as well as
932
1166
 
933
1167
  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
1168
 
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.
1169
+ Serialization is 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. To handle this, the `#call` method must accept a `:context` keyword, which is an instance of `Cuprum::Rails::Serializers::Context`. Each context is initialized with a set of serializers that are used to serialize attributes, array items or hash values, associated models, or otherwise nested properties. All of this is handled automatically inside the controller action.
936
1170
 
937
1171
  ```ruby
938
1172
  class StructSerializer < Cuprum::Rails::Serializers::JsonSerializer
939
- def call(struct, serializers:)
1173
+ def call(struct, context:)
940
1174
  struct.each_pair.with_object do |(key, value), hsh|
941
- hsh[key] = super(value, serializers: serializers)
1175
+ hsh[key] = super(value, context: context)
942
1176
  end
943
1177
  end
944
1178
  end
945
1179
 
946
1180
  serializer = StructSerializer.new
1181
+ context = Cuprum::Rails::Serializers::Context.new(
1182
+ serializers: Cuprum::Rails::Serializers::Json.default_serializers
1183
+ )
947
1184
  struct =
948
1185
  Struct
949
1186
  .new(:series, :author, :titles)
950
1187
  .new('The Locked Tomb', 'Tamsyn Muir', ['Gideon the Ninth', 'Harrow the Ninth'])
951
- serializer.call(struct, serializers: Cuprum::Rails::Serializers::Json.default_serializers)
1188
+ serializer.call(struct, context: context)
952
1189
  #=> {
953
1190
  # 'series' => 'The Locked Tomb',
954
1191
  # 'author' => 'Tamsyn Muir',
@@ -956,13 +1193,13 @@ serializer.call(struct, serializers: Cuprum::Rails::Serializers::Json.default_se
956
1193
  # }
957
1194
  ```
958
1195
 
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.
1196
+ 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 a serialization context that contains 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
1197
 
961
1198
  `Cuprum::Rails` defines the following serializers:
962
1199
 
963
1200
  **Cuprum::Rails::Serializers::Json::Serializer**
964
1201
 
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.
1202
+ The base class for JSON serializers. Takes a configured `context:` and finds the serializer for the given object, then calls that serializer with the object and the given context.
966
1203
 
967
1204
  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
1205
 
@@ -1002,44 +1239,100 @@ class RecordSerializer < Cuprum::Rails::Serializers::Json::AttributesSerializer
1002
1239
  end
1003
1240
 
1004
1241
  class BookSerializer < RecordSerializer
1005
- attribute :title
1006
- attribute :author
1007
- attribute :series
1242
+ attributes \
1243
+ :title,
1244
+ :author,
1245
+ :series
1008
1246
  end
1009
1247
 
1010
1248
  class DetailedBookSerializer < BookSerializer
1011
- attribute :category
1012
- attribute :published_at
1249
+ attributes \
1250
+ :category,
1251
+ published_at: :iso8601
1013
1252
  end
1014
1253
 
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',
1254
+
1255
+ context = Cuprum::Rails::Serializers::Context.new(
1256
+ serializers: Cuprum::Rails::Serializers::Json.default_serializers
1257
+ )
1258
+ book = Book.new(
1259
+ id: 0,
1260
+ title: 'Nona The Ninth',
1261
+ author: 'Tamsyn Muir',
1262
+ series: 'The Locked Tomb',
1263
+ category: 'Science Fiction and Fantasy',
1264
+ published_at: Date.new(2022, 9, 13)
1022
1265
  )
1023
1266
 
1024
- BookSerializer.new.call(book, serializers: serializers)
1267
+ BookSerializer.new.call(book, context: context)
1025
1268
  #=> {
1026
1269
  # 'id' => 0,
1027
1270
  # 'title' => 'Nona The Ninth',
1028
1271
  # 'author' => 'Tamsyn Muir',
1029
- # 'series' => 'The Locked Tombs'
1272
+ # 'series' => 'The Locked Tomb'
1030
1273
  # }
1031
1274
 
1032
- DetailedBookSerializer.new.call(book, serializers: serializers)
1275
+ DetailedBookSerializer.new.call(book, context: context)
1033
1276
  #=> {
1034
1277
  # 'id' => 0,
1035
1278
  # 'title' => 'Nona The Ninth',
1036
1279
  # 'author' => 'Tamsyn Muir',
1037
1280
  # 'series' => 'The Locked Tombs',
1038
1281
  # 'category' => 'Science Fiction and Fantasy',
1039
- # 'published_at' => nil
1282
+ # 'published_at' => '2022-09-13'
1040
1283
  # }
1041
1284
  ```
1042
1285
 
1043
1286
  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
1287
 
1045
1288
  We also define a `DetailedBookSerializer` which inherits from `BookSerializer`. This allows us to reuse the attributes defined for our basic book serializer.
1289
+
1290
+ Attribute serializers also inherit from `PropertiesSerializer` (see below), and can use the `.property` method. This allows the user to serialize compound properties, or to handle cases where the desired serialization key is different from the name of the attribute.
1291
+
1292
+ #### Property Serializers
1293
+
1294
+ Property serializers define a set of properties to be serialized. This is useful for serializing data structures such as database models.
1295
+
1296
+ ```ruby
1297
+ class EmployeeSerializer < Cuprum::Rails::Serializers::Json::PropertiesSerializer
1298
+ property :first_name, scope: :first_name
1299
+ property(:last_name, &:last_name)
1300
+ property(:full_name) { |user| "#{user.first_name} #{user.last_name}" }
1301
+ property(:hire_date, scope: :hire_date, &:iso8601)
1302
+ property :salary, serializer: BigDecimalSerializer.new
1303
+ property :department, scope: %i[department name]
1304
+ end
1305
+
1306
+ context = Cuprum::Rails::Serializers::Context.new(
1307
+ serializers: Cuprum::Rails::Serializers::Json.default_serializers
1308
+ )
1309
+ employee = Employee.new(
1310
+ first_name: 'Alan',
1311
+ last_name: 'Bradley',
1312
+ hire_date: Date.new(1977, 5, 25)
1313
+ salary: BigDecimal.new('100000')
1314
+ department: Department.new(name: 'Engineering')
1315
+ )
1316
+
1317
+ EmployeeSerializer.new.call(employee, context: context)
1318
+ #=> {
1319
+ # first_name: 'Alan',
1320
+ # last_name: 'Bradley',
1321
+ # full_name: 'Alan Bradley',
1322
+ # hire_date: '1977-05-25',
1323
+ # salary: '0.1e6',
1324
+ # department: 'Engineering'
1325
+ # }
1326
+ ```
1327
+
1328
+ Here, we're creating a serializer for our `Employee` model, which serializes each employee into a `Hash` with the configured `property` keys.
1329
+
1330
+ - The property name determines the key used to serialize the value in the resulting Hash.
1331
+ - The `:scope` keyword determines the initial value to be serialized.
1332
+ - If the scope is `nil`, the object as a whole will be passed to the mapping and then the serializer.
1333
+ - If the scope is a String or a Symbol, then the value of the object property with that key will be mapped. Above, the `first_name` property is defined with `scope: :first_name`, so the initial value will be `employee.first_name`.
1334
+ - If the scope is an Array, then the value of the nested property at those keys will be mapped. Above, the `department` property is defined with `scope: %i[department name]`, so the initial value will be `employee.department.name`.
1335
+ - The `:serializer` keyword specifies how the mapped value is to be serialized. It should either be an instance of `Cuprum::Rails::Serializers::BaseSerializer` or a `Proc` that accepts two parameters: an `object` argument, and a `:context` keyword that is the current `Cuprum::Rails::Serializers::Context`.
1336
+ - The block, if any, is used to map the scoped value before passing it to the serializer. Above, the `full_name` property is generated by combining the `employee.first_name` and `employee.last_name`.
1337
+
1338
+ When the `property` does not specify a `scope`, a `serializer`, or provide a block, it will raise an `ArgumentError`. This would otherwise serialize the object itself using the default serializers. If, for some reason, this is the desired behavior, pass an identity block or `&:itself` as the mapping block.