Within Gadget, you have the ability to store files in 2 different ways depending on your application's use case.
Static Assets: Files that are stored and served as-is without any processing or modification by the application. These assets are typically fixed and do not change frequently. Examples of static assets include images, CSS files, JavaScript files, and other files that make up the frontend of your application.
Dynamic Assets: Files that are generated or processed by the application at runtime. These assets are typically generated dynamically based on user input, database queries, or other dynamic factors, and can change frequently. For example, in a social media application, user-uploaded profile pictures or images in posts would be considered dynamic assets, as they are generated or processed by the application based on user input.
Static assets within the frontend
If you require external assets to enhance your app such as images, videos, fonts, stylesheets, etc, frontend hosting within Gadgets supports storing and serving these static assets.
It's easy to import static assets into your Gadget projects. Drag and drop your file assets to the frontend folder in your Gadget editor and they are immediately ready to be imported wherever needed.
Dynamic files in Gadget are stored as file fields on a Gadget model. Files are uploaded when creating or updating a record and then deleted when the file is no longer referenced by a record. File URLs can then be fetched alongside other data for those records (or related records) using the GraphQL API. Gadget hosts and stores files securely in the cloud. Only users with permission to access the file's owner records can access the stored files.
Adding a new file field
To store files, you must add a new file field to one of your Gadget models. Each file field can store one file, so if you need to store multiple files associated with the same record, you can add several file fields to the model, or create a new child model with the file field and a belongs to relationship back to the parent.
File field validations
If desired, you can use Gadget's built-in validations for file fields to limit the size of uploaded files or to only accept images. You can also use custom code validations to implement custom business logic to work with the uploaded file and decide if it is valid or not.
Validating a file field
JavaScript
1importgotfrom"got";
2
3exportdefaultasync({ record, field, errors })=>{
4// the value of a field inside a validation is a metadata blob describing the uploaded file
5const metadata = record[field.apiIdentifier];
6// you can access the file's mimeType to get a MIME type string
7 metadata.mimeType;// #=> "image/png"
8// you can access the file's size in bytes
9 metadata.byteSize;// #=> 70
10// you can access the file's URL in cloud storage
12// you must explicitly retrieve the file if you want to work with the contents of it
13const contents =awaitgot(metadata.url);
14// do stuff with the file contents as a Buffer to validate it
15if(hasBadBytes(contents)){
16 errors.add(field.apiIdentifier,"has bad bytes");
17}
18};
You can use modules from npm to work with file contents as well to validate them. For example, you can validate if a file is a valid PDF file by retrieving it from cloud storage using got, and then using the is-pdf-valid module to check the file's contents:
Validating a PDF file field
JavaScript
1importgotfrom"got";
2importvalidPDFfrom"is-pdf-valid";
3
4exportdefaultasync({ record, field, errors })=>{
5const metadata = record[field.apiIdentifier];
6const contents =awaitgot(metadata.url).buffer();
7// do stuff with the file contents as a Buffer to validate it
8if(!validPDF(contents)){
9 errors.add(field.apiIdentifier,"is not a valid PDF file");
10}
11};
File fields in actions
Records with file fields can be read and written inside actions. The record object passed to your function will have a stored file's metadata present for inspection by default, and the file contents can be accessed by downloading the file from cloud storage.
Accessing file content in an action
JavaScript
1importgotfrom"got";
2
3exportdefaultasync({ record, field, errors })=>{
4// the value of a field inside a validation is a metadata blob describing the uploaded file
5const metadata = record.someFileField;
6// you can access the file's name. either the original one provided or a generate one if none was specified
7 metadata.fileName;// #=> "test-image.png"
8// you can access the file's mimeType to get a MIME type string
9 metadata.mimeType;// #=> "image/png"
10// you can access the file's size in bytes
11 metadata.byteSize;// #=> 70
12// you can access the file's URL in cloud storage
file fields are set the same way as other fields in Gadget using GraphQL mutations. The input file data must be provided
in one of the supported formats, and then an optional fileName and mimeType can also be set on the file. Gadget's API will store the
file in cloud storage, and then return a URL to access the file when the record is queried later.
File data can be submitted in a number of ways: as a string, as a multipart request, as a URL, or directly uploaded to cloud storage.
Each of these methods is used by passing an object with specific keys as the input value for the field in an action on the model.
The StoredFileInput object
All file field inputs must be given as objects conforming to the GraphQL StoredFileInput object schema, both in GraphQL calls and calls made using the generated JavaScript client.
GraphQL
1typeStoredFileInput{
2# Sets the file contents using this string, interpreting the string as base64 encoded bytes. This is useful for creating files quickly and easily if you have the file contents available already, but, it doesn't support files larger than 10MB, and is slower to process for the backend. Using multipart file uploads or direct-to-storage file uploads is preferable.
3base64:GraphQLString
4# Sets the file contents using binary bytes sent alongside a GraphQL mutation as a multipart POST request. Gadget expects this multipart POST request to be formatted according to the GraphQL multipart request spec defined at https://github.com/jaydenseric/graphql-multipart-request-spec. Sending files as a multipart POST request is supported natively by the generated Gadget JS client using File objects as variables in API calls. This method supports files up to 100MB.
5file:GraphQLUpload
6# Sets the file contents by fetching a remote URL and saving a copy to cloud storage. File downloads happen as the request is processed so they can be validated, which means large files can take some time to download from the existing URL. If the file can't be fetched from this URL, the action will fail.
7copyURL:GraphQLURL
8# Sets the file contents using a token from a separate upload request made with the Gadget storage service. Uploading files while a user is completing the rest of a form gives a great user experience and supports much larger files, but requires client side code to complete the upload, and then pass the returned token for this field.
9directUploadToken:GraphQLString
10# Sets this file's mime type, which will then be used when serving the file during read requests as the `Content-Type` HTTP header. If not set, Gadget will infer a content type based on the file's contents.
11mimeType:GraphQLString
12# Sets this file's stored name, which will then be used as the file's name when serving the file during read requests. If not set, Gadget will infer a filename if possible.
13fileName:GraphQLString
14}
base64 encoded string uploads
Files can be uploaded when creating records by using the contents of the file inline as as base64 encoded string using the base64 field of the input.
For example, if we have an Image model that has a stringname field and a fileimage field, we can create a new Image record like this:
When queried, the created Image record will have an id, createdAt, and updatedAt field like any other model, the name field set to A Test Image, and the imagefile field set to an object with a url field.
base64 performance
base64 encoded string file uploads are convenient if you have the file contents in memory already and want an easy way to submit small files to the Gadget server. But, base64 uploads don't perform well compared to the other methods. Embedding the file contents in the request like this causes delayed processing in the Gadget API, as the whole request string needs to be sent to the API before it can successfully parse the whole JSON blob. Large files have to be base64 encoded which adds extra processing time.
If you are working in a browser or using the generated JavaScript client for your Gadget app, prefer using the file key or the directUploadToken key, especially if your users are ever uploading large files.
Uploads using the File object
Files can be uploaded when creating records by using the File object that browsers expose. File objects can be accessed from <input type="file" />s or from drag and drop operations. For more information on using Files in a web application, see MDN.
To upload a File, pass it at the file key in the input object for a file field.
For example, if we have an Image model that has a stringname field and a fileimage field, we can create a new Image record like this:
Setting an image using a File object
JavaScript
1// get the file from an <input type="file" />
2// see https://developer.mozilla.org/en-US/docs/Web/API/File/Using_files_from_web_applications#getting_information_about_selected_files
4// use the File object at the file key of the input for the image field
5api.image.create({
6name:"A Test Image",
7image:{
8file: file,
9},
10});
File object performance
Uploading File objects with the file works well for small files, as the browser is able to stream the initial GraphQL request, and then the files to the backend API, allowing processing of the request while the files are being uploaded to cloud storage. Large files can take a long time to stream, so if your users intend to upload large files, the Direct Upload method is best.
Gadget supports a maximum file size of 100MB per file uploaded with the file key and a maximum of 10 files per request.
Uploads using the Multipart Request spec
Gadget GraphQL APIs implement the GraphQL Multipart Request specification which allows submitting files alongside other variables in a GraphQL mutation. The Gadget-generated JavaScript client uses this specification to upload File objects automatically, but if you're working in a different language or with a different client, you can pass files to Gadget using any client which implements this spec.
For example, to upload a test.png file with cURL according to the multipart request spec, you can run the following command:
Gadget supports storing files directly from an existing URL on the web. Instead of a client uploading a local file, Gadget will copy a file from a URL into cloud storage.
Gadget copies files from remote URLs once when the URL is first submitted, and then uses its cloud storage system to host the file. Once a
file field has been set on a record, take care to use the URL that Gadget generates for the file, instead of the
original URL. The Gadget-generated URL will be governed by the Gadget permissions system, and use Gadget's performant CDN for hosting.
To set the value of a file field, use the copyURL key of the input object in a GraphQL mutation or API client call.
Using copyURL to download an image
1await api.photo.create({
2name:"A photo!",
3image:{
4// create a record copying an image from an existing URL
5copyURL:"https://example.com/image.png",
6},
7});
1queryCreatePhoto{
2createPhoto(
3photo:{
4name:"A photo!"
5image:{
6# create a record copying an image from an existing URL
7copyURL:"https://example.com/image.png"
8}
9}
10){
11photo{
12id
13name
14image{
15url
16}
17}
18}
19}
Direct URL upload performance
When possible, copying from remote URLs to upload files tends to perform well. Server-to-server communication over the internet is generally much faster than a user's connection, so if files are already hosted somewhere, copying them into Gadget can be faster and more convenient for the user than downloading and re-uploading them.
Direct uploads using tokens
Uploading files to a remote server can be slow sometimes, especially if the files are large or the user's internet connection is constrained. Often, users upload files to an application at the same time as filling in other data, and they select which file they want to upload before filling out the rest of the form. That means there's a good opportunity to give users a good experience by uploading their files in the background while they complete the rest of the form. If the file is large, starting the upload as soon as possible helps as well to get the user to complete the submission faster by starting the upload sooner.
To support this workflow, Gadget supports direct uploads, which allows the user's browser to start an upload as soon as the file is ready, and upload it directly to cloud storage. The form can then be submitted to Gadget later with a token for the file.
The process works like this:
The user's browser requests the /api/files/upload-target Gadget API to retrieve an upload URL and a file token
The user's browser uploads a file to the upload URL
The user finishes filling out the form with any other data
Once the upload is complete and any other client-side validations are satisfied, the user's browser submits the form to Gadget and runs a model action. The action input references the file using the directUploadToken key of the StoredFileInput for the file field on the model.
Gadget will retrieve metadata from the uploaded file in cloud storage and associate it with the file field on the record.
Getting a file upload URL and token
Direct upload URLs and tokens need to be fetched before a user uploads a file or submits a form. To get a token, you can use the await api.getDirectUploadToken() API client function, the gadgetMeta { directUploadToken { url token } } GraphQL query, or the /api/files/upload-target REST endpoint.
Once you have a direct upload url, you must PUT the file's contents to the url. The url is signed for security and expires after 3 hours to avoid abuse. This means you can't just generate the url once and hardcode it in your code -- you must generate a new url for each upload by your form.
Once the upload has been completed, use the token as the value of the directUploadToken key in the input to the file field.
6// pass the token instead of a file key or base64 key
7directUploadToken: token,
8// set the filename or the mime type if needed
9fileName:"test-image.png",
10},
11});
1mutation{
2createImage(
3image:{
4name:"A Test Image"
5image:{
6# pass the token instead of a file key or base64 key
7directUploadToken:"v4.local.someopaquetoken"
8# set the filename or the mime type if needed
9fileName:"test-image.png"
10}
11}
12){
13success
14image{
15id
16name
17image{
18url
19}
20}
21}
22}
Direct upload tokens can be re-used to associate the same uploaded file with many records. If you complete the upload to the url once, you can call many Gadget actions with the same token. Direct upload urls can't be re-used to upload more than one file. You must create a new url and token for each unique file uploaded by the user.
Direct upload tokens expire after 3 hours. Users can't take longer than this between uploading a file to the url and using the token in an action.
Writing frontend upload forms
Some frontend code is required to coordinate file upload when using the direct token upload method. The token should only be sent to Gadget once the upload is complete and the user has completed the form. The general flow is:
Wait for the user to submit a file
Get a url and token before you start the file upload with api.getDirectUploadToken()
Upload the file to cloud storage in the background with fetch or similar
Store the token in the state while the user completes the rest of the form
Submit the action to Gadget once the user has finished the form and the upload has completed
Using React as an example, we can listen to a drop event on an element and upload the dropped file right to cloud storage:
JavaScript
1importReact,{ useState }from"react";
2import{ api }from"../api";// or wherever your Gadget client is constructed
27// once it has been uploaded, give the parent component the token and file name to submit with the other data in the form
28 props.onFileTokenChange(token, file.name);
29setFileName(file.name);
30}finally{
31setIsUploading(false);
32}
33}}
34>
35{isUploading ?"Uploading ...": fileName ? fileName :"Drop a file here"}
36</div>
37);
38}
More examples of uploading files to Gadget can be found in the Gadget
Examples on GitHub.
Direct file upload performance
Direct file uploads perform the best for both the user and the systems involved out of any of the upload methods. The user benefits because the file upload is started (and completed) earlier, which allows their form submission to complete as soon as they have finished filling it out. If the form is complicated or long and the user is likely to get some validation errors, it's also great to be able to re-use the token for the uploaded file for a second or third submission instead of re-uploading the file again.
Direct file uploads also support the biggest files as there is no file size limit. Because the user's browser is uploading directly to cloud storage, the cloud storage system's optimized frontends can handle the upload and give it the best chance of completing with very high request timeouts and great internet connectivity.
Storing new files within actions
To store a new file programmatically within an action, make an API call with the api object to run an action with the same inputs as detailed above. The api object available in the action context works the same as an api object in an external script, so all the upload methods are available and ready for use inside actions as well.
For example, we can create file contents by serializing an object to JSON, and then store the JSON contents in a file in cloud storage:
Creating a File in an action
JavaScript
1importgotfrom"got";
2
3exportdefaultasync({ api, record })=>{
4// build some example data
5const data ={
6name: record.name,
7description: record.description,
8};
9
10const fileContents =JSON.stringify(data);
11// create a new record of a model with a File field named `file`
12await api.someModel.create({
13file:{
14// set the filename
15fileName:"example.json",
16// base64 encode the file contents to pass to Gadget
Currently, the Internal API does not support uploading new files to Gadget, so you must use your application's Public API to manage files inside effects.
Retrieving files
file fields are accessed like other fields in Gadget using the generated GraphQL API. Gadget returns several details about
a stored file like it's url, fileName, mimeType, and byteSize. File field details can be fetched when querying individual records or
lists of records.
Getting a file
1api.image.findMany({
2select:{
3// get details of the owning record
4id:true,
5name:true,
6image:{
7// get the file's URL, suitable for embedding in an <img src="..."/> tag
8url:true,
9// get the file's filename, including extension
10fileName:true,
11// get the file's mime type
12mimeType:true,
13// get the file's size in bytes
14byteSize:true,
15},
16},
17});
1query{
2images{
3edges{
4node{
5# get details of the owning record
6id
7name
8image{
9# get the file's URL, suitable for embedding in an <img src="..."/> tag
10url
11# get the file's filename, including extension
12fileName
13# get the file's mime type
14mimeType
15# get the file's size in bytes
16byteSize
17}
18}
19}
20}
21}
Marking files as private
file fields in Gadget can be marked as private or not. When the field is private, unauthenticated users aren't able to
access the URL for a file at all. For authenticated users, Gadget generates securely signed URLs to files that expire after 3 hours. This is
ideal because it means if a user logs out or their access is revoked, they can no longer access the file after the 3 hour window has
elapsed. Private files are not CDN cached which is often desirable if they can contain any kind of sensitive data.
If you want anyone to be able to access files, like the images on a blog, or profile photos on a social network, you can mark the field as not private. In this instance, Gadget will generate URLs that never expire so users are always able to access the file, and the files will be cached on Gadget's CDN for maximum performance.
If you change a file field's private configuration, existing uploaded files are not re-permissioned. If you want to make all the files stored by a field private, you would need to re-upload each one to recreate them with the new access permissions.
Deleting stored files
Files are deleted from storage when they are no longer referenced by a record. There are 2 ways to delete stored files:
Delete the record that owns the file. When a record is deleted, all files associated with it are deleted as well.
Set a file field to null.
Updating a file field on a record also deletes the original file from storage.
Where are files stored?
Under the hood, Gadget uses Google Cloud Storage to store files. The Gadget API uploads submitted files to a private bucket and uses Google Cloud Storage's Signed URL functionality to grant access to files when a user can prove they have access to the owning record.
It's not currently possible to use your own Cloud Storage bucket for files.