Serving Active Storage Attachments from a Rails API with React & Nginx
By: Jeremy Bennett
Tags: Postgres, Active Storage, Nginx, Rails, React

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.

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: DB db: 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 ... private def 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:

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 { ...

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

}

proxy_pass



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!