How to upload a file with Grails 4
Learn how to upload files with Grails 4; transfer them to a folder, save them as byte[] in the database or upload them to AWS S3.
Authors: Sergio del Amo
Grails Version: 4.0.1
1 Grails Training
Grails Training - Developed and delivered by the folks who created and actively maintain the Grails framework!.
2 Getting Started
In this guide, you are going to write three different domain classes. Moreover, you are going to write controllers, services, and views associated with them.
We are going to explore different ways to save the uploaded files; byte[] in the database, local folder or remote server (AWS S3).
We are going to use a Command Object to allow only images ( JPG or PNG ) uploads.
2.1 What you will need
To complete this guide, you will need the following:
-
Some time on your hands
-
A decent text editor or IDE
-
JDK 1.8 or greater installed with
JAVA_HOME
configured appropriately
2.2 How to complete the guide
To get started do the following:
-
Download and unzip the source
or
-
Clone the Git repository:
git clone https://github.com/grails-guides/grails-upload-file.git
The Grails guides repositories contain two folders:
-
initial
Initial project. Often a simple Grails app with some additional code to give you a head-start. -
complete
A completed example. It is the result of working through the steps presented by the guide and applying those changes to theinitial
folder.
To complete the guide, go to the initial
folder
-
cd
intograils-guides/grails-upload-file/initial
and follow the instructions in the next sections.
You can go right to the completed example if you cd into grails-guides/grails-upload-file/complete
|
3 Writing the Application
The initial
folder contains a Grails 4 application created with the web
profile.
It has several Controllers, GORM Data Services, Domain classes and a simple CRUD interface built with GSPs.
It is an app to list tourism resources; hotels, restaurants, and points of interest.
In the next sections, you are going to add a feature to the app. Each of those resources may include a featured image.
-
For restaurants, we are going to store the image bytes directly in the domain class.
-
For points of interest, we transfer the file to a folder.
-
For hotels, we upload the file to AWS S3.
3.1 Featured Image Command Object
Each of our application entities ( hotels, restaurants and point of interest ) will have a featured image.
To encapsulate the validation of the file being uploaded; we use a Grails Command Object
package example.grails
import grails.validation.Validateable
import org.springframework.web.multipart.MultipartFile
class FeaturedImageCommand implements Validateable {
MultipartFile featuredImageFile
Long id
Integer version
static constraints = {
id nullable: false
version nullable: false
featuredImageFile validator: { val, obj ->
if ( val == null ) {
return false
}
if ( val.empty ) {
return false
}
['jpeg', 'jpg', 'png'].any { extension -> (1)
val.originalFilename?.toLowerCase()?.endsWith(extension)
}
}
}
}
1 | Only files ending with JPG or PNG are allowed |
3.2 Increase allowed file size
Grails 4’s default file size is 128000 (~128KB).
We are going to allow 25MB files uploads.
25 * 1024 * 1024 = 26.214.400 bytes
grails:
controllers:
upload:
maxFileSize: 26214400
maxRequestSize: 26214400
4 Restaurant - Upload and save as byte[]
This part of the Guide illustrates how to upload a file to a Grails server app and store the file’s bytes in the database.
4.1 Restaurant Domain Class
Modify Restaurant
domain class. Add two properties featuredImageBytes
and
featuredImageContentType
.
package example.grails
class Restaurant {
String name
byte[] featuredImageBytes (1)
String featuredImageContentType (2)
static constraints = {
featuredImageBytes nullable: true
featuredImageContentType nullable: true
}
static mapping = {
featuredImageBytes column: 'featured_image_bytes', sqlType: 'longblob' (3)
}
}
1 | Use a domain class property to store the bytes of the image in the database |
2 | Content-Type of the image. E.g. 'image/jpeg' |
3 | Images may be big files; we use the mapping closure to configure the sqlType |
4.2 Restaurant Views
We are going to modify the views slightly to include functionality which enables to upload a featured image.
In the restaurant’s listing, display only the restaurant’s name in the table. Checkout the Fields Plugin f:table documentation.
<f:table collection="${restaurantList}" properties="['name']"/>
Edit and create form will not allow users to set featured image’s content type or byte[]
<f:all bean="restaurant" except="featuredImageBytes,featuredImageContentType"/>
<f:all bean="restaurant" except="featuredImageBytes,featuredImageContentType"/>
In the show restaurant page, add a button which takes the user to a featured image edit page.
<g:link class="edit" action="editFeaturedImage" resource="${this.restaurant}"><g:message code="restaurant.featuredImageUrl.edit.label" default="Edit Featured Image" /></g:link>
Modify RestaurantController
. Add editFeaturedImage
controller’s action:
def editFeaturedImage(Long id) {
Restaurant restaurant = restaurantDataService.get(id)
if (!restaurant) {
notFound()
}
[restaurant: restaurant]
}
editFeaturedImage
GSP is identical to edit
GSP but it uses an g:uploadForm
instead of a g:form
<g:uploadForm name="uploadFeaturedImage" action="uploadFeaturedImage">
<g:hiddenField name="id" value="${this.restaurant?.id}" />
<g:hiddenField name="version" value="${this.restaurant?.version}" />
<input type="file" name="featuredImageFile" />
<fieldset class="buttons">
<input class="save" type="submit" value="${message(code: 'restaurant.featuredImage.upload.label', default: 'Upload')}" />
</fieldset>
</g:uploadForm>
4.3 Upload Featured Image Service
The uploadFeatureImage
controller’s action uses the previously described command
object to validate the upload form submission. If it does not find validation errors, it invokes a Gorm Data Service.
def uploadFeaturedImage(FeaturedImageCommand cmd) {
if (cmd == null) {
notFound()
return
}
if (cmd.hasErrors()) {
respond(cmd.errors, model: [restaurant: cmd], view: 'editFeaturedImage')
return
}
Restaurant restaurant = restaurantDataService.update(cmd.id,
cmd.version,
cmd.featuredImageFile.bytes,
cmd.featuredImageFile.contentType)
if (restaurant == null) {
notFound()
return
}
if (restaurant.hasErrors()) {
respond(restaurant.errors, model: [restaurant: restaurant], view: 'editFeaturedImage')
return
}
Locale locale = request.locale
flash.message = crudMessageService.message(CRUD.UPDATE, domainName(locale), cmd.id, locale)
redirect restaurant
}
The complete GORM Data Service is shown in the following code snippet:
package example.grails
import grails.gorm.services.Service
@Service(Restaurant)
interface RestaurantDataService {
Restaurant get(Long id)
List<Restaurant> list(Map args)
Number count()
void delete(Serializable id)
Restaurant update(Serializable id, Long version, String name)
Restaurant update(Serializable id, Long version, byte[] featuredImageBytes, String featuredImageContentType) (1)
Restaurant save(String name)
}
1 | The first argument should be the id of the object to update. If any of the parameters don’t match up to a property on the domain class then a compilation error will occur. |
4.4 Load byte array Image
Create an action in RestaurantController
which renders the restaurant’s featured image:
def featuredImage(Long id) {
Restaurant restaurant = restaurantDataService.get(id)
if (!restaurant || restaurant.featuredImageBytes == null) {
notFound()
return
}
render file: restaurant.featuredImageBytes,
contentType: restaurant.featuredImageContentType
}
Use the g:createLink
GSP tag to reference the previous controller action in the img src attribute.
<h1><f:display bean="restaurant" property="name" /></h1>
<g:if test="${this.restaurant.featuredImageBytes}">
<img src="<g:createLink controller="restaurant" action="featuredImage" id="${this.restaurant.id}"/>" width="400"/>
</g:if>
5 Point of Interest - Upload and transfer
In this part of this guide, we have separated the Grails application from the uploaded images. We use MAMP to run a local apache server at port 8888 pointing to the folder data.
5.1 Point of Interest Domain Class
Modify PointOfInterest
domain class. Add a property featuredImageUrl
package example.grails
class PointOfInterest {
String name
String featuredImageUrl (1)
static constraints = {
featuredImageUrl nullable: true
}
}
1 | Use this property to save the url where the image can be retrieved. |
5.2 Point of Interest Views
We are going to modify the GSP views slightly to include functionality which enables to upload a featured image.
In Point of Interest’s listing, display only the hotel’s name in the table. Checkout the Fields Plugin f:table documentation.
<f:table collection="${pointOfInterestList}" properties="['name']"/>
Edit and create form will not allow users to set featured image’s url
<f:all bean="pointOfInterest" except="featuredImageUrl"/>
<f:all bean="pointOfInterest" except="featuredImageUrl"/>
Instead we add a button which takes the user to a featured image edit page.
<g:link class="edit" action="editFeaturedImage" resource="${this.pointOfInterest}"><g:message code="pointOfInterest.featuredImageUrl.edit.label" default="Edit Featured Image" /></g:link>
Modify PointOfInterestController
. Add a controller’s action named editFeaturedImage
:
def editFeaturedImage(Long id) {
PointOfInterest pointOfInterest = pointOfInterestDataService.get(id)
if (!pointOfInterest) {
notFound()
return
}
[pointOfInterest: pointOfInterest]
}
editFeaturedImage
GSP is identical to edit
GSP but it uses an g:uploadForm
instead of a g:form
<g:uploadForm name="uploadFeaturedImage" action="uploadFeaturedImage">
<g:hiddenField name="id" value="${this.pointOfInterest?.id}" />
<g:hiddenField name="version" value="${this.pointOfInterest?.version}" />
<input type="file" name="featuredImageFile" />
<fieldset class="buttons">
<input class="save" type="submit" value="${message(code: 'pointOfInterest.featuredImage.upload.label', default: 'Upload')}" />
</fieldset>
</g:uploadForm>
5.3 Upload Featured Image Service
The uploadFeatureImage
controller’s action uses the previously described command
object to validate the upload form submission. If it does not find validation errors, it invokes a service.
def uploadFeaturedImage(FeaturedImageCommand cmd) {
if (cmd.hasErrors()) {
respond(cmd, model: [pointOfInterest: cmd], view: 'editFeaturedImage')
return
}
PointOfInterest pointOfInterest = uploadPointOfInterestFeaturedImageService.uploadFeatureImage(cmd)
if (pointOfInterest == null) {
notFound()
return
}
if (pointOfInterest.hasErrors()) {
respond(pointOfInterest, model: [pointOfInterest: pointOfInterest], view: 'editFeaturedImage')
return
}
Locale locale = request.locale
flash.message = crudMessageService.message(CRUD.UPDATE, domainName(locale), pointOfInterest.id, locale)
redirect pointOfInterest
}
We configure the local server url which will host the uploaded images and the local folder path where we are going to save the images to.
grails:
guides:
cdnFolder: /Users/sdelamo/Sites/GrailsGuides/UploadFiles
cdnRootUrl: http://localhost:8888
Service uses the transferTo
method to transfer the file to a local file path.
In case of error, the service deletes the file it has previously transfered
package example.grails
import grails.config.Config
import grails.core.support.GrailsConfigurationAware
import groovy.transform.CompileStatic
@SuppressWarnings('GrailsStatelessService')
@CompileStatic
class UploadPointOfInterestFeaturedImageService implements GrailsConfigurationAware {
PointOfInterestDataService pointOfInterestDataService
String cdnFolder
String cdnRootUrl
@Override
void setConfiguration(Config co) {
cdnFolder = co.getRequiredProperty('grails.guides.cdnFolder')
cdnRootUrl = co.getRequiredProperty('grails.guides.cdnRootUrl')
}
@SuppressWarnings('JavaIoPackageAccess')
PointOfInterest uploadFeatureImage(FeaturedImageCommand cmd) {
String filename = cmd.featuredImageFile.originalFilename
String folderPath = "${cdnFolder}/pointOfInterest/${cmd.id}"
File folder = new File(folderPath)
if ( !folder.exists() ) {
folder.mkdirs()
}
String path = "${folderPath}/${filename}"
cmd.featuredImageFile.transferTo(new File(path))
String featuredImageUrl = "${cdnRootUrl}//pointOfInterest/${cmd.id}/${filename}"
PointOfInterest poi = pointOfInterestDataService.updateFeaturedImageUrl(cmd.id, cmd.version, featuredImageUrl)
if ( !poi || poi.hasErrors() ) {
File f = new File(path)
f.delete()
}
poi
}
}
The service updates featuredImageUrl
with the help of a GORM Data Service:
package example.grails
import grails.gorm.services.Service
@SuppressWarnings(['LineLength', 'UnusedVariable', 'SpaceAfterOpeningBrace', 'SpaceBeforeClosingBrace'])
@Service(PointOfInterest)
interface PointOfInterestDataService {
PointOfInterest get(Long id)
List<PointOfInterest> list(Map args)
Number count()
void delete(Serializable id)
PointOfInterest save(String name)
PointOfInterest updateName(Serializable id, Long version, String name)
PointOfInterest updateFeaturedImageUrl(Serializable id, Long version, String featuredImageUrl)
}
6 Hotel - Upload to AWS S3
For Hotel
domain class we are going to upload the file to Amazon AWS S3.
First, Install Grails AWS SDK S3 Plugin
Modify you build.grade
file
repositories {
...
..
maven { url 'http://dl.bintray.com/agorapulse/libs' }
}
dependencies {
...
..
.
compile "org.grails.plugins:aws-sdk-s3:$awsSdkS3Version"
compile "com.amazonaws:aws-java-sdk-s3:1.11.375"
}
Define awsSdkS3Version
in gradle.properties
grailsVersion=4.0.1
gormVersion=7.0.2.RELEASE
hibernateCoreVersion=5.4.0.Final
gebVersion=3.2
htmlunitDriverVersion=2.47.1
htmlunitVersion=2.35.0
assetPipelineVersion=3.0.11
awsSdkS3Version=2.2.4
We are going to upload our files to an already existing bucket.
grails:
plugins:
awssdk:
s3:
region: eu-west-1
bucket: grails-guides
6.1 Hotel Domain Class
Modify Hotel
domain class. Add two properties featuredImageKey
and featuredImageUrl`
package example.grails
class Hotel {
String name
String featuredImageUrl (1)
String featuredImageKey (2)
static constraints = {
featuredImageUrl nullable: true
featuredImageKey nullable: true
}
}
1 | We store the AWS S3 url of the featured image |
2 | We store the file path. Useful to delete the file if necessary. |
6.2 Hotel Views
We are going to modify the views slightly to include functionality which enables to upload a featured image.
In Hotel’s listing, display only the hotel’s name in the table. Checkout the Fields Plugin f:table documentation.
<f:table collection="${hotelList}" properties="['name']"/>
Edit and create form will not allow users to set featured image’s url
<f:all bean="hotel" except="featuredImageUrl,featuredImageKey"/>
<f:all bean="hotel" except="featuredImageUrl,featuredImageKey"/>
Instead we add a button which takes the user to a featured image edit page.
<g:link class="edit" action="edit" resource="${this.hotel}"><g:message code="default.button.edit.label" default="Edit" /></g:link>
Modify HotelController
. Add a controller’s action named editFeaturedImage
:
def editFeaturedImage(Long id) {
Hotel hotel = hotelDataService.get(id)
if (!hotel) {
notFound()
return
}
[hotel: hotel]
}
editFeaturedImage
GSP is identical to edit
GSP but it uses an g:uploadForm
instead of a g:form
<g:uploadForm name="uploadFeaturedImage" action="uploadFeaturedImage">
<g:hiddenField name="id" value="${this.hotel?.id}" />
<g:hiddenField name="version" value="${this.hotel?.version}" />
<input type="file" name="featuredImageFile" />
<fieldset class="buttons">
<input class="save" type="submit" value="${message(code: 'hotel.featuredImage.upload.label', default: 'Upload')}" />
</fieldset>
</g:uploadForm>
6.3 Upload image to S3
The uploadFeatureImage
controller’s action uses the previously described command
object to validate the upload form submission. If it does not find validation errors, it invokes a service.
def uploadFeaturedImage(FeaturedImageCommand cmd) {
if (cmd.hasErrors()) {
respond(cmd.errors, model: [hotel: cmd], view: 'editFeaturedImage')
return
}
def hotel = uploadHotelFeaturedImageService.uploadFeatureImage(cmd)
if (hotel == null) {
notFound()
return
}
if (hotel.hasErrors()) {
respond(hotel.errors, model: [hotel: hotel], view: 'editFeaturedImage')
return
}
Locale locale = request.locale
flash.message = crudMessageService.message(CRUD.UPDATE, domainName(locale), hotel.id, locale)
redirect hotel
}
Service uses the amazonS3Service
provided by the plugin to upload the
file to AWS S3. In case of error, the service deletes the file it has previously uploaded.
package example.grails
import grails.gorm.transactions.Transactional
import grails.plugin.awssdk.s3.AmazonS3Service
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
@CompileStatic
@Slf4j
@Transactional
class UploadHotelFeaturedImageService {
AmazonS3Service amazonS3Service
HotelDataService hotelDataService
Hotel uploadFeatureImage(FeaturedImageCommand cmd) {
String path = "hotel/${cmd.id}/${cmd.featuredImageFile.originalFilename}"
String s3FileUrl = amazonS3Service.storeMultipartFile(path, cmd.featuredImageFile)
Hotel hotel = hotelDataService.update(cmd.id, cmd.version, path, s3FileUrl)
if ( !hotel || hotel.hasErrors() ) {
deleteFileByPath(path)
}
hotel
}
boolean deleteFileByPath(String path) {
boolean result = amazonS3Service.deleteFile(path)
if (!result) {
log.warn 'could not remove file {}', path
}
result
}
}
The domain class instance is updated with the help of GORM Data Service:
package example.grails
import grails.gorm.services.Service
@Service(Hotel)
interface HotelDataService {
Hotel get(Long id)
List<Hotel> list(Map args)
Number count()
void delete(Serializable id)
Hotel save(String name)
Hotel update(Serializable id, Long version, String name)
Hotel update(Serializable id, Long version, String featuredImageKey, String featuredImageUrl)
}
6.4 Run the App with AWS Access and Secret
If you do not provide credentials, a credentials provider chain will be used that searches for credentials in this order:
Environment Variables - AWS_ACCESS_KEY_ID and AWS_SECRET_KEY
Java System Properties - aws.accessKeyId andaws.secretKey`
Instance profile credentials delivered through the Amazon EC2 metadata service (IAM role)
Thus, we can run the app with:
./gradlew bootRun -Daws.accessKeyId=XXXXX -Daws.secretKey=XXXXX