-
Notifications
You must be signed in to change notification settings - Fork 66
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[ARP POA Submission] (#3) Decision Creation Controller Action (#101919) #20747
base: master
Are you sure you want to change the base?
Conversation
Generated by 🚫 Danger |
81c62b8
to
376a2b6
Compare
8913b2c
to
39e7b0a
Compare
39e7b0a
to
222c36c
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correctness and error handling pivot.
The PRs and their sizes have been very approachable.
|
||
module AccreditedRepresentativePortal | ||
class PowerOfAttorneyFormSubmission < ApplicationRecord | ||
enum :status, %w[enqueue_succeeded enqueue_failed succeeded failed].index_by(&:itself) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah yes, this is what I'll do for these from now on. Have done values.zip(values).to_h
before.
@@ -7,7 +7,7 @@ | |||
post 'form21a', to: 'form21a#submit' | |||
|
|||
resources :in_progress_forms, only: %i[update show destroy] | |||
resources :power_of_attorney_requests, only: %i[index show] do | |||
resources :power_of_attorney_requests, only: %i[index show create] do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Snuck in?
resolving = PowerOfAttorneyRequestDecision.create!(type:, creator:) | ||
case decision_params[:type] | ||
when 'acceptance' | ||
raise UnprocessableEntity, 'Reason must be blank' if reason.present? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's revert to letting our model validates
declarations carry out this error logic. But we can then map them to errors that are contextual to our Accept
service within that service (just reiterating the comment that describes the new strategy).
) | ||
service = PowerOfAttorneyRequestService::Accept.new(@poa_request, creator, reason) | ||
poa_form_submission = service.call | ||
raise UnprocessableEntity, poa_form_submission.error_message if poa_form_submission.enqueue_failed? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FYI, looks like service raises in the enqueue-failed case so we would never have hit this code.
raise e | ||
# All other errors: save error data on form submission | ||
rescue => e | ||
create_error_submission(e.message) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We shouldn't make a second form submission. Rather we should update the one form submission as failing to enqueue.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry I left this here from before the API call had to be moved out of the transaction.
service = BenefitsClaims::Service.new(poa_request.claimant.icn) | ||
response = service.submit2122(attributes) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we could encapsulate all 3 of the methods here.
The line of thinking that led me here (and I think there are others) was that attributes
isn't a good name, so then I thought of service_payload
for accuracy against the current code structure, but then the redundancy of service_
is potentially a smell (I really wouldn't go as far as calling it that though).
I don't think we necessarily need a class for it, but maybe a single entry function that might call helper functions with repetitively prefixed names (e.g. submit
and submit_payload
), just some thoughts.
The name attributes
really does stick out though.
def attributes | ||
{}.tap do |a| | ||
a[:veteran] = veteran_data | ||
a[:serviceOrganization] = service_org_data |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This one is a nitpick that doesn't need to be changed—
About naming, I tend to avoid abbreviations unless it gets really dire (poa_request
seems like a good convention for local vars for example). Here organization_data
comes to mind.
end | ||
|
||
def form_data | ||
@form_data ||= JSON.parse(poa_request.power_of_attorney_form.data) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PowerOfAttorneyForm#parsed_data
is already this method implementation and it is memoized so you can call it repeatedly too.
form_submission | ||
# TODO: call PowerOfAttorneyFormSubmissionJob.perform_async(poa_form_submission) | ||
# Transient 5xx errors: delete objects created, re-raise error | ||
rescue *transient_error_types => e |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this sticks around I would actually make it a constant. Fine to do a calculation of another constant like that (and freeze it potentially): TRANSIENT_ERROR_TYPES
.
|
||
post "/accredited_representative_portal/v0/power_of_attorney_requests/#{poa_request.id}/decision", | ||
params: { decision: { type: 'acceptance', reason: nil } } | ||
VCR.use_cassette('lighthouse/benefits_claims/power_of_attorney_decision/404_response.yml') do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Possible that VCR comments on PR stack PR preceding this one will lead to changes here. I'll wait until later to review more closely.
376a2b6
to
4852ce1
Compare
222c36c
to
ce83be8
Compare
2f63694
to
755ca90
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- Streamlined service control flow
- Consistent transient error handling
- Resolution & resolving creation encapsulation
- Note about service vs controller separation of concerns
def accept!(creator, reason) | ||
resolving = PowerOfAttorneyRequestDecision.create!( | ||
type: PowerOfAttorneyRequestDecision::Types::ACCEPTANCE, creator: | ||
) | ||
# This form triggers the uniqueness validation, while the `create_resolution!` form triggers | ||
# a more obscure `RecordNotSaved` error that is less functional for getting validation errors. | ||
## | ||
PowerOfAttorneyRequestResolution.create!(power_of_attorney_request: self, resolving:, reason:) | ||
end | ||
|
||
def decline!(creator, reason) | ||
resolving = PowerOfAttorneyRequestDecision.create!( | ||
type: PowerOfAttorneyRequestDecision::Types::DECLINATION, creator: | ||
) | ||
# This form triggers the uniqueness validation, while the `create_resolution!` form triggers | ||
# a more obscure `RecordNotSaved` error that is less functional for getting validation errors. | ||
PowerOfAttorneyRequestResolution.create!( | ||
power_of_attorney_request: self, | ||
resolving:, | ||
reason: | ||
) | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's finally encapsulate and hide this implementation for co-creating the resolution & resolving:
- Creating the two records in the correct sequence
- Invoking AR's validations in a way that is useful to us
- Wrapping them in a transaction
module AccreditedRepresentativePortal
class PowerOfAttorneyRequestDecision < ApplicationRecord
class << self
def create_acceptance!(**attrs)
create_with_resolution!(type: Types::ACCEPTANCE, **attrs)
end
def create_declination!(**attrs)
create_with_resolution!(type: Types::DECLINATION, **attrs)
end
private
def create_with_resolution!(creator:, type:, **resolution_attrs)
PowerOfAttorneyRequestResolution.create_with_resolving!(
resolving: new(type:, creator:),
**resolution_attrs
)
end
end
end
end
module AccreditedRepresentativePortal
class PowerOfAttorneyRequestResolution < ApplicationRecord
class << self
##
# Adding this public class method in addition to `create!` because this
# implementation causes the uniqueness validation to be expressed.
#
def create_with_resolving!(resolving:, **attrs)
transaction do
resolving.create!
create!(resolving:, **attrs)
end
end
end
end
end
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also note the use of keyword args to mimic AR model interface.
def accept!(creator, reason) | ||
resolving = PowerOfAttorneyRequestDecision.create!( | ||
type: PowerOfAttorneyRequestDecision::Types::ACCEPTANCE, creator: | ||
) | ||
# This form triggers the uniqueness validation, while the `create_resolution!` form triggers | ||
# a more obscure `RecordNotSaved` error that is less functional for getting validation errors. | ||
## | ||
PowerOfAttorneyRequestResolution.create!(power_of_attorney_request: self, resolving:, reason:) | ||
end | ||
|
||
def decline!(creator, reason) | ||
resolving = PowerOfAttorneyRequestDecision.create!( | ||
type: PowerOfAttorneyRequestDecision::Types::DECLINATION, creator: | ||
) | ||
# This form triggers the uniqueness validation, while the `create_resolution!` form triggers | ||
# a more obscure `RecordNotSaved` error that is less functional for getting validation errors. | ||
PowerOfAttorneyRequestResolution.create!( | ||
power_of_attorney_request: self, | ||
resolving:, | ||
reason: | ||
) | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Only downside I see for exposing #accept!
and #decline
on PowerOfAttorneyRequest
is that these verbs could potentially lead someone to believe that this performs all the business logic that it needs to, but it doesn't. We lifted these verbs from the model layer to the service layer where it would be appropriate to handle all the business logic.
On this point I am mostly indifferent though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
are mark_accepted!
and mark_declined!
more descriptive?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@pixiitech I think that's a good idea. I've seen mark_*
plenty of times.
This makes me think of what methods ActiveRecord::Enum
exposes e.g. accepted!
and tempts to do that...
Up to you!
# All other errors: save error data on form submission, will result in a 500 | ||
rescue => e | ||
update_submission_with_error(e.message) | ||
raise |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unexpected errors are transient and should be handled accordingly (blow up and roll back the resolution & resolving).
raise Error.new(e.message, :bad_request) | ||
# Transient 5xx errors: delete objects created, raise TransientError | ||
rescue *TRANSIENT_ERROR_TYPES => e | ||
form_submission.delete |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A form submission wouldn't have been created if we're in this rescue. This use of memoization is creating and then instantly deleting this record. Can we create a more streamlined flow? I think using local variables is potentially the exact control flow we want, while memoized instance variables is a complicating departure from a direct control flow.
@resolution = poa_request.accept!(creator, reason) | ||
response = service.submit2122(form_payload) | ||
|
||
form_submission.update( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should have straightforward control flow that is based around two operations:
- Make a failed submission on a failure
- Make a successful submission on a success
rescue PowerOfAttorneyRequestService::Accept::Error => e | ||
render json: { errors: [e.message] }, status: e.status |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we can get away with this for now, but I suspect the correct layered design is as follows:
Service surfaces a variety of kinds of errors that express what went wrong semantically. The service understands the internals of what went wrong but is happy to obfuscate those internal details that are too much information for the service's caller to need to know about.
This controller is a caller of the service. It should have the responsibility of interpreting the variety of kinds of (semantic) errors that the service exposes and then it decides what response should be returned from the controller.
For now:
If the responses returned from the controller look principled from the outside world, but the design doesn't follow above, that is fine; we can punt on such a refactoring until later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
- Added accept and decline services - Error handling - Request spec
157adf6
to
4ba6ca5
Compare
Note: Delete the description statements, complete each step. None are optional, but can be justified as to why they cannot be completed as written. Provide known gaps to testing that may raise the risk of merging to production.
Summary
Related issue(s)
Testing done
Screenshots
Note: Optional
What areas of the site does it impact?
(Describe what parts of the site are impacted andifcode touched other areas)
Acceptance criteria
Requested Feedback
(OPTIONAL)What should the reviewers know in addition to the above. Is there anything specific you wish the reviewer to assist with. Do you have any concerns with this PR, why?