Serving Active Storage Attachments from a Rails API with React & Nginx

Jeremy Bennett
5 min readApr 26, 2021

--

On a recent project I was tasked with storing files to a Postgres database as attachments. This guide will walk through the process of adding attachments to an existing resource using Rails 6.1.3.1, ruby 3.0.0, Postgres 11.9, as well as the nginx config for serving the files in production. In this example, we will be uploading a small logo, a large logo, and a login background as images. The following additions assume that the majority of the base code for manipulating a resource exists in both your Rails and React apps already, and will only focus on providing the attachment functionality to a sample create and update action. This pattern is useful if you need the ability to create and update attachments through a single request to create or update a resource, rather than having separate route and controller actions purely to handle attachments.

0. A test! A test! A test upon your app!

Using rspec:

it 'creates attachments on a resource' do  attachment_attributes = {  large_logo: fixture_file_upload('test-large-logo.png',
'image/png', binary: true),
small_logo: fixture_file_upload('test-small-logo.png',
'image/png', binary: true),
login_background: fixture_file_upload('test-login-bg.png',
'image/png', binary: true)
}
sign_in(@user_who_can_attach_things) patch api_v1_resource_url, params: {
resource: attachment_attributes },
headers: form_headers
body = JSON.parse(response.body)
small_logo_url = body['resource']['small_logo']
expect(small_logo_url).to include('change-photo.png')end

This test will let us know if we’re able to attach the small logo to an existing resource and that the url produced in our API response contains the attachment’s original filename. The fixture_file_upload method with look for these fixture files in /spec/fixtures/files. Additional tests could include that the string in question is indeed a URL, that a resource can be created from scratch with an attachment, that a resource can be deleted, et cetera.

  1. Install Active Storage & active_storage_db

This is the easy part. From your Rails root directory, run the following in a shell:

rails active_storage:install

Which will generate the required migrations for Active Storage, then of course:

rails db:migrate

A friendly, medium-depth dive of what these commands do can be found here.

In our Gemfile:

gem 'active_storage_db'

Don’t forget to run bundle install any time the Gemfile is modified.

Other resources may point to active-storage-database-service however that project is no longer maintained.

2. Declare attachment associations on your resource model

has_one_attached :small_logo
has_one_attached :large_logo
has_one_attached :login_background

3. Add validations

It would be quite silly to accept any filetype. For our case we were looking only for images, and I found this gem to be quite helpful to eliminate the need for custom validations:

In our Gemfile:

gem 'active_storage_validations'

And in our resource model:

validates :large_logo, :small_logo, :login_background, content_type: %w[image/png image/svg+xml image/jpeg]

4. Declare your Active Storage service(s)

Create or edit config/storage.yml , drop in:

test: service: DBdb: service: DB

5. Update resource controller. If your app requires attachments to be updated when a resource is updated the update method here will also allow you to pass 'delete' as a parameter value to delete an attachment, or update it in place. For our create action, all we need to do is update our strong params and the model associations we defined earlier will take care of creating the attachments and associated blobs.

def show  
@resource = Resource.find(params[:id])
render json: {
resource: @resource.as_json.merge(build_attachment_hash)
}
end
...def update
@resource = Resource.find(params[:id])
attachments_changed = false
attachment_params.each do |key, val|
if val == 'delete'
@resource.public_send(key).purge
else
@resource.public_send(key).attach(val)
end
attachments_changed = true
end
if @resource.update(update_params) || attachments_changed
attachments = build_attachment_hash(@resource)
render json: {
resource: @resource.as_json.merge!(attachments)
}
else
render json: @resource.errors, status: :unprocessable_entity
end
end
...privatedef resource_params
params.require(:resource)
.permit(
:small_logo,
:large_logo,
:login_background
...
end
def attachment_params
params.require(:resource)
.permit(
:small_logo,
:large_logo,
:login_background
)
end
def build_attachment_hash(resource)
attachment_keys = %i[small_logo large_logo login_background]
attachment_keys.each_with_object({}) do |key, obj|
obj.merge!(key => create_attachment_url(key, resource))
end
end
def create_attachment_url(key, resource)
rails_blob_url(resource.public_send(key)).to_s if resource.public_send(key).present?
end

That wraps up the changes to our API and the test we initially wrote should pass.

Time to update the client:

  1. Make sure you initialize some empty string placeholders in state. They’re strings because as you see above, the rails API will produce a URL for each attachment. When creating a new resource, we need an empty string per attachment to hold the path to the attachment on the client machine. For updating, we need both this string and a string to hold the rails generated URL for the existing attachment.
const INITIAL_STATE = {
addResourceDialog: {
resourceName: "",
smallLogoImage: "",
largeLogoImage: "",
loginBackgroundImage: ""
},
editResourceDialog: {
id: "",
resourceName: "",
smallLogoImage: "",
largeLogoImage: "",
loginBackgroundImage: "",
smallLogoImageUrl: "",
largeLogoImageUrl: "",
loginBackgroundImageUrl: ""
},
...
}

2. Update the reducer, setters, and selectors.

export const reducer = (state = INITIAL_STATE, action) => {
const { type, payload } = action
switch (type) {
case SET_EDIT_RESOURCE_FIELD:
return assoc('editResourceDialog', {
...state.editResourceDialog,
...payload.editResourceFormFieldUpdates
}, state)
case SET_ADD_RESOURCE_FIELD:
return assoc('addResourceDialog', {
...state.addResourceDialog,
...payload.newResourceFormFieldUpdates
}, state)
case SET_ADD_RESOURCE_DIALOG_OPEN:
return merge(state, {
dialog: { ...state.dialog,
addResourceDialogOpen: payload.open
},
addResourceDialog: payload.open ? INITIAL_STATE.addResourceDialog : state.addResourceDialog
})
case SET_EDIT_RESOURCE_DIALOG_OPEN: {
const editResource = state.resources.find(u => u?.id === payload.id)
return merge(state, {
dialog: { ...state.dialog,
editResourceDialogOpen: payload.open
},
editResourceDialog: {
resourceName: editResource?.name,
smallLogoImageUrl: editOrg?.small_logo,
largeLogoImageUrl: editOrg?.large_logo,
loginBackgroundImageUrl: editOrg?.login_background,
id: editResource?.id
}
})
}
...
}
}
export const setAddOrgField = newOrgFormFieldUpdates => ({
type: SET_ADD_ORG_FIELD,
payload: { newOrgFormFieldUpdates }
})
export const setEditOrgField = editOrgFormFieldUpdates => ({
type: SET_EDIT_ORG_FIELD,
payload: { editOrgFormFieldUpdates }
})
export const setEditOrgDialogOpen = ({ open, id }) => ({
type: SET_EDIT_ORG_DIALOG_OPEN,
payload: { open, id }
})
export const setAddOrgDialogOpen = ({ open }) => ({
type: SET_ADD_ORG_DIALOG_OPEN,
payload: { open }
})
export const selectors = ({
...
addOrgDialog: path([NAMESPACE, 'addOrgDialog']),
editOrgDialog: path([NAMESPACE, 'editOrgDialog'])
})

3. Create your async action handlers, here’s a thunk for creating a new resource, editing should only differ by http method. Because we’re uploading binaries, we must use a multipart form request and namespace what we append to our resource so the Rails API can properly parse the parameters.

export const addResource = () => (dispatch, getState, { http }) => {
const { resourceName, smallLogoImage, largeLogoImage, loginBackgroundImage } = getState()[NAMESPACE].addResourceDialog
const form = new FormData()
form.append('resource[name]', resourceName)if (smallLogoImage) {
form.append('resource[small_logo]', smallLogoImage)
}
if (largeLogoImage) {
form.append('resource[large_logo]', largeLogoImage)
}
if (loginBackgroundImage) {
form.append('resource[login_background]', loginBackgroundImage)
}
if (smallLogoImage || largeLogoImage || loginBackgroundImage) {
dispatch(setUploadingResource(true))
}
return http
.post('/resources', form)
.then(() => {
createSnackNotification(AlertLevel.Success, 'Success', 'Resource created')
})
.catch(httpErrorHandler('Failed to create resource'))
.finally(() => {
setTimeout(() => {
const { searchAndFilter, pagination } = getState()[NAMESPACE]
dispatch(getResources({ searchAndFilter, pagination }))
dispatch(setAddResourcegDialogOpen({ open: false }))
dispatch(setUploadingResource(false))
}, 500)
})
}

At this point, fire up your app locally and you should be able to update and create a resource with attachments.

Depending on your app’s deployment model your server may need to account for the temporary URLs being produced by rails to serve your attachments. Taking a closer look at those urls:

https://www.myapp.com/rails/active_storage/blobs/redirect/eyJfTlFpbHMiOnsibWVzc3FnZSI6IkJBaHDCZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--3c7219a60b45703415dce5fde6206db473fc77fd/change-photo.png

While the rest of the API is being served under the path /api/v1 these new attachments come under a new path of /rails/active_storage/blobs/redirect. What gives? According to the Rails 6.1.3 docs, the url method we are using in our resource controller returns a short-lived url for private files (the default) and a permanent url for public files. Abstracting this also allows us to swap out services that hold our attachments (to s3 or azure blobs, for instance).

To get an nginx server to expose these urls without throwing a ‘Mixed Content’ error update your nginx.conf:

server { ...  location /rails {
proxy_pass http://localhost:<PORT>/rails;
proxy_set_header Host $http_host;
}
location /active_storage_db {
proxy_pass http://localhost:<PORT>/active_storage_db;

proxy_set_header Host $http_host;
}
...
}

Now you should be able to access those rails generated URLs and their associated attachments in production. This concludes this walkthrough on adding attachments using Rails/React on Nginx. I hope you’ve found this helpful, please leave a comment if anything needs improving, and a clap if you found this helpful!

--

--