Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Cover image for Hanami Shrine - file handling in Hanami
Krzysztof
Krzysztof

Posted on • Originally published at2n.pl

     

Hanami Shrine - file handling in Hanami

Continuing with the latest streak of Hanami focused posts I am bringing you another example of a common feature and implementation, translated to Hanami.

I recently showed someemail-password authentication with Hanami, before that it wasprogress bar feature, now we will handle image uploads.

As a sidenote, nice thing about writing about Hanami is that you get to use all those beautiful pictures of blossoming trees. This time with a shrine in the background.

Shrine

We will be usingshrine and I want to start this post by saying a few words about it.

It is great.

I could end it here, but just to clarify: it is a file attachment toolkit for Ruby applications. It is very flexible and can be used with any ORM, any storage service, and any (relevant) processing library. It is also very well documented and has a lot of plugins. We will be making use of those.

ActiveStorage and Shrine

I worked with ActiveStorage on Rails a lot and I have been frustrated by it many, many times. It is overcomplicated, and does not follow the usual doctrine of Rails, that aims to make developers happy.ActiveStorage does not make me happy. It makes me frustrated. It makes me spend a lot of time on documentation, cause even though I keep using it, due to its design and DSL I cannot, for the life of me, remember multiple important details about it. I need to constantly remind myself how to do certain, even mundane, things with it, despite doing them repeatedly.

Shrine does make me happy. It is stupidly simple. I rarely look up the documentation after working with it on few projects. The documentation is also written well and is very concise, something that a lot of rails guides pages fail at in my opinion.

Hanami with ROM and Shrine

So if you have read my previous posts about Hanami, you probably noticed I am using ROM-RB. As an ORM, rom provides minimum infrastructure for mapping and persistence by design. It presents data with immutable structs. Those structs are disjointed from the layer that persists new data to the database.

You might think "oh-oh", this means we have to go through a lot of hoops to make Shrine work with ROM. But you would be wrong. In fact, you are wrong. Shrine is very flexible and can be used with any ORM, remember? It wasn't always that easy, but with a shrine plugincomes with the gem, but has to be enabled we can make it work, surprisingly easily.

Setup

Once again, we have to start with configuring the tools used. If you have seen previous posts, you can figure out that we need to add a new file inconfig/providers

#config/providers/shrine.rbHanami.app.register_provider(:shrine)dopreparedorequire"shrine"require"shrine/storage/file_system"require"shrine/storage/s3"endstartdos3_options={bucket:target["settings"].s3_bucket,region:target["settings"].s3_region,access_key_id:target["settings"].s3_access_key_id,secret_access_key:target["settings"].s3_secret_access_key}permanent_storage=ifHanami.env?(:test)Shrine::Storage::FileSystem.new("spec/tmp/",prefix:"uploads")elseShrine::Storage::S3.new(**s3_options)endShrine.storages={cache:Shrine::Storage::FileSystem.new("public",prefix:"uploads/cache"),# temporarystore:permanent_storage}Shrine.plugin:entityShrine.plugin:cached_attachment_dataShrine.plugin:restore_cached_dataShrine.plugin:form_assignShrine.plugin:rack_fileShrine.plugin:validation_helpersShrine.plugin:determine_mime_typeregister:shrine,Shrineendend
Enter fullscreen modeExit fullscreen mode

We use quite some plugins, some are for caching the file, handling file through form, validations, mime_types. The most important one isentity which is a plugin for handling files being attaches to immutable objects.
This is a simple setup, that saves the files we add in specs to spec/tmp folder, and real files to s3. It also adds some plugins that we will use in the feature. We also need to add some settings:

#config/settings.rbmoduleLibusclassSettings<Hanami::Settingssetting:s3_bucket,constructor:Types::Stringsetting:s3_region,constructor:Types::Stringsetting:s3_access_key_id,constructor:Types::Stringsetting:s3_secret_access_key,constructor:Types::Stringendend
Enter fullscreen modeExit fullscreen mode

As per usual, we need a corresponding.env file with key-value pairs likeS3_BUCKET=your_bucket_name etc.

As for the database structure, we need a table that needs a file attachment. Lets say we have a tablebooks. In the migration (assuming it was the initial migration that created the table) we have:

column:image_data,:jsonb,null:true
Enter fullscreen modeExit fullscreen mode

And other columns. JSONB can also be text, but this setup is explained well in shrinerb. Compare this to active storage that adds two entire tables to your database, and stores all files there. Here you have data that you need, connected to relevant table.

Last part of the setup would be to create our Uploader.

#lib/libus/image_uploader.rbrequire'shrine'moduleLibusclassImageUploader<Hanami.app["shrine"]TYPES=%w[image/jpeg image/png image/webp].freezeEXTENSIONS=%w[jpg jpeg png webp].freezeMAX_SIZE=5*1024*1024MIN_SIZE=1024Attacher.validatedovalidate_sizeMIN_SIZE..MAX_SIZE# 1kB..5MBvalidate_mime_typeImageUploader::TYPESendendend
Enter fullscreen modeExit fullscreen mode

This is a class that will handle... uploading of our files. It describes what kind of files we accept, what is the size limit, and what is the minimum size. It is a simple example, but you can probably already see how you can expand it to fit your needs.

And just as a formality, we need some routes:

#slices/main/config/routes.rbget'/books',to:'books.index'get'/books/:id/edit',to:'books.edit'patch'/books/:id',to:'books.update'
Enter fullscreen modeExit fullscreen mode

As in the progress bar example, final feature will use a little bit ofhtmx, so bear in mind that this is also setup and affects the html and routing design.

Hanami code

So we have a spot in database, we have shrine setup. How do we implement it all? This framework is not described in shrine getting started guide, so we need to start from the ground up. I add some specs:

#spec/requests/image_upload_spec.rbRSpec.describe'ImageUploadSpec',:db,type: :requestdocontext'when photo is uploaded'docontext"when user is logged in"dolet!(:user){factory[:user,name:"Guy",email:"my@guy.com"]}let!(:book){factory[:book]}it'changes the photo'dologin_asuserpatch"/books/#{book.id}",{id:book.id,book:{image:Rack::Test::UploadedFile.new("spec/fixtures/image.png")}}expect(JSON.parse(rom.relations[:books].first.image_data)["id"]).tobe_a(String)expect(last_response.status).tobe(302)endendendend
Enter fullscreen modeExit fullscreen mode

Very simple happy path test that checks that if we upload an image, it is saved in the database. It uses the simplest way to upload the file, and a simplest way to check the database. Ifimage_data has anid in its jsonb structure, then it has a file attached.

Since we will have to modify our database, we probably will need to use a repository. It does not have a lot right now (only presenting relevant parts):

#slices/main/repositories/books.rbmoduleMainmoduleRepositoriesclassBooks<Main::Repo[:books]struct_namespaceMain::Entitiescommands:create,update: :by_pk,delete: :by_pk[...]endendend
Enter fullscreen modeExit fullscreen mode

We do have anupdate command plugged in, but we can't simply update the image_data field it would skip all Shrine validations and checks, basically making the uploader obsolete.
Speaking of uploader, we need to add it to the entity, that was specified in the repository:

# slices/main/entities/book.rbmoduleMainmoduleEntitiesclassBook<ROM::StructincludeLibus::ImageUploader::Attachment(:image)attr_writer:image_dataendendend
Enter fullscreen modeExit fullscreen mode

attr_writer is needed cause normally we don't change stuff on entities, but in this case, we need to change the image_data field, so we need to add a writer for it.
Of course that does not mean that the entity will gain the ability to change the data, it is still not its responsibility. But in case we use entity in a form, we can change the data in the form, and then pass it to the repository.

As for the include, it means that the Uploader will do its work onimage related fields, meaning it expectsimage_data attribute on the connected object instance. If we provide that, we get access to all the shrine goods.

We should deal with the repository side and check if attaching the image does its job:

#spec/slices/main/repositories/books_spec.rbcontext"#image_attach"dolet!(:dune){factory[:book,title:"Dune",isbn_13:"9780441172719"]}it"succeeds"doexpect(described_class.new.image_attach(dune.id,Rack::Test::UploadedFile.new("spec/fixtures/image.png")).image).tobe_a(Libus::ImageUploader::UploadedFile)endend
Enter fullscreen modeExit fullscreen mode

A bit more precise spec, that assumes we will add a new method to the repo, that will attach an image and that the returned entity will have access to theimage method entity and that also assumes that entity will have animage method (we did not define it, that is the shrine include doing this work for us).

As for theimage_attach method, lets add it to the repository:

#slices/main/repositories/books.rbdefimage_attach(id,image)book=books.by_pk(id).one!attacher=book.image_attacherattacher.form_assign({"image"=>image})attacher.finalizeself.update(book.id,attacher.column_values)end
Enter fullscreen modeExit fullscreen mode

It uses theattacher we get access to from the entity, thanks to the include. Attacker gives us form_assign method that handles - you guessed it - uploads coming in form a form. We could use other methods, but for now we only expect files coming in from a form. After that we tell the attacher to finalize the upload, so move the file from cache, to persistent storage. After this we get access tocolumn_values, which is what we should update the database record with.

Step by step, each easier than the last one, we receive the file (in cache), save it to the database, and the permanent storage (S3). Zero magic and only one column needed. 5 lines of code in the repo. We also have a way to check if the file is attached to the entity, and we can use it in the view.

View

Okey, so we got an index view, with list of books. On it, we can clickedit on a row, and change the books picture (cover or whatever).
Part of that template could be:

#slices/main/templates/books/index.html.erb<%books.eachdo|book|%><tr><th><label><inputtype="checkbox"class="checkbox"/></label></th><td><divid=<%="picture-#{book.id}"%>class="flex"><divclass="avatar"><divclass="mask mask-squircle w-32 h-32"><%=book.avatar(:sm)%></div></div></div></td><td><divclass="font-bold"><%=book.title%></div></td><td><divclass="font-bold"><%=book.category%></div></td><th><divclass="text-sm opacity-50"><%=book.author.name%></div></th><th><buttonclass="btn btn-sm btn-ghost"hx-get="/books/<%=book.id%>/edit"hx-target="<%="#picture-#{book.id}"%>"hx-swap="innerHTML"hx-trigger="click">        Edit</button></th></tr><%end%>
Enter fullscreen modeExit fullscreen mode

A quick rundown of whathtmx does here: it replaces the content (inner) of thediv with the idpicture-#{book.id} with the content of the response from the server, that is the edit form. It happens on button click.

Template with the form is nothing special

#slices/main/templates/books/edit.html.erb<%=form_for:book,"/books/#{id}",method: :patchdo|f|%><%=f.file_field:image,hidden:true,value:book.image_data%><%=f.file_field:image%><buttonclass="btn btn-primary"type="submit">Submit</button><%end%>
Enter fullscreen modeExit fullscreen mode

Here you can see why we need image_data attribute in the entity. We use it in the form, so it needs access to it.

Update

At this point, we basically need one line of code to upload the file.

#slices/main/actions/books/update.rbmoduleMainmoduleActionsmoduleBooksclassUpdate<Main::ActionincludeDeps[books_repo:'repositories.books']paramsdorequired(:id).filled(:integer)required(:book).schemadooptional(:image).value(:hash)endenddefhandle(request,response)halt422,{errors:request.params.errors}.to_jsonunlessrequest.params.valid?books_repo.image_attach(request.params[:id],request.params[:book][:image][:tempfile])response.redirect_to"/books"endendendendend
Enter fullscreen modeExit fullscreen mode

image_attach handles the shrine side of things that will validate the file against the rules we have set up. I skipped error handling for this, since it is no different that any other handling, specially since shrine also stores the initially uploaded file in the cache, and inserts it back in the form. Well it does not do so magically, we added the hidden field, that takes its value fromimage_data attribute.

Conclusion

ActiveStorage needs two tables. It breaks the usual style of rails, where every table is a model clearly defined in our app. Both tables have their corresponding objects, but they are hidden from out regular code, and not really changeable by us. We need to create an association to every model, and use the same tables for files on every model. The implementation is hidden from us, and file validation is not build in. Documentation is weird and the DSL is confusing.

On the other side, we get Hanami, with very clear and explicit way of setting providers and extensions, along with composable Shrine, that only needs one column and an include statement in an entity. Gives us a clear documentation and great DSL.

Hanami and Shrine are a great fit, and this integration was so easy and natural that I really appreciated how far a good design takes you in software development. Sure it might be obvious that good design and in general good code, provide good results, but it is rather rare to see two things integrate so well and seamless. Even if two pieces of software are well written and designed, there might be glitches and a lot of friction cause the design principles might differ. Not here though.

Also please do not take my criticism of ActiveStorage as a general slander of Rails or its contributors. I love Rails, and I owe my career to it and its many great contributors, I still love working with Rails. I just think that ActiveStorage is a bit of a misstep in the otherwise great framework. I am sure it will get better, but for now, I am sticking with Shrine.

Top comments(2)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss
CollapseExpand
 
katafrakt profile image
Paweł Świątkowski
  • Location
    Wrocław, Poland
  • Pronouns
    he/him
  • Work
    Elixir and ReScript developer @ Walnut
  • Joined

Thanks for another excellent article on Hanami. I'm comparing your setup with mine and I see you managed to avoid usingshrine-rom gem, which I did not. On the other hand, I don't have anattr_writer on my entity ;) (not sure if this is thanks to the gem)

CollapseExpand
 
krzykamil profile image
Krzysztof
RoR dev with interests in too many other stuff
  • Location
    Białystok
  • Education
    Law degree
  • Work
    backend (mostly) developer at 2N IT
  • Joined

At first I haven't looked at the gem too much to be honest, not sure how they get around the entity immutability, but will take a look out of curiosity.

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

RoR dev with interests in too many other stuff
  • Location
    Białystok
  • Education
    Law degree
  • Work
    backend (mostly) developer at 2N IT
  • Joined

More fromKrzysztof

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp