Dear Hanami,
This is a question about best practices for transforms.
TL;DR Where are we putting our transformation/normalization logic for create/update methods in our Repos?
I have a contract called NewApplicationContract
with the optional field secret
. However the actual schema for Applications requires a secret_digest
. Ideally if no secret is provided we’ll generate one with SecureRandom.hex(32)
but then we need to hash it with BCrypt before we store it in the database. The logic for doing so is pretty straight forward:
module IDP
module Repos
class ApplicationRepo < DB::Repo
def create(validate: true, **attributes)
if validate
validator = Slice['contracts.new_application_contract'].call(application: attributes)
return Failure(validator.errors) unless validator.success?
end
secret = attributes.delete(:secret) || SecureRandom.hex(32)
attributes[:secret_digest] = BCrypt::Password.create(secret)
application = applications.changeset(Changesets::Application::Create, attributes).commit
Success([application, secret])
rescue StandardError => e
Failure(e)
end
end
end
end
However while this particular create method is pretty straight forward, it’s not sustainable. What happens when I have a repo that needs 5 or 10 transformations done to the input? For example:
def create(validate: true, **attributes)
if validate
validator = Slice['contracts.new_application_contract'].call(application: attributes)
return Failure(validator.errors) unless validator.success?
end
secret = attributes.delete(:secret) || SecureRandom.hex(32)
attributes[:secret_digest] = BCrypt::Password.create(secret)
attributes[:host] = attributes[:host].downcase.strip
if attributes[:redirect_uris].is_a?(Array)
attributes[:redirect_uris] = attributes[:redirect_uris].reject(&:empty?).map do |redirect_uri|
redirect_uri.downcase.strip
end
end
application = applications.changeset(Changesets::Application::Create, attributes).commit
Success(application:, secret:)
rescue StandardError => e
Failure(e)
end
Now the method has become way more complex and is arguably doing way too many things. Does this type of logic belong in a changeset? I tried introducing a “transformer” layer but it felt weird and I couldn’t figure out dry-transform
well enough to pull it off (defining custom functions in dry-transform
seems to be a whole ordeal). But defining this in a changeset seems awkward as well:
module IDP
module Changesets
module Application
class Create < DB::Changeset::Create
map do |data|
secret = data.delete(:secret) || SecureRandom.hex(32)
data[:secret_digest] = BCrypt::Password.create(secret)
end
end
end
end
end
In this scenario we have a few issues
- I can’t use this for different input scenarios
- I can’t expose the secret in the result of the create method anymore
- I have to create a
Create
,Update
, andDelete
changeset for every relation
Which brings me to the actual questions at hand: Where are we meant to put our normalization and transformation logic for create/update methods from our Repos? I suppose I could throw this in an operation but even there I have questions on best practices for organization. Does anyone have more verbose documentation on how to use dry-transform
specifically on how to define custom functions? Would a transformers
layer make sense in a slice? What patterns are you implementing to deal with this type of work?