This page is a work in progress.You can help improve it. →

Adding Images To Restaurants

Adding Images To Restaurants

It would be nice to add an image for the restaurant so we know what the place looks like.

When storing user-provided assets we can choose between hosting these assets ourselves or using an external service. Storing them ourselves gives us more control. However, external services are often optimized for this process, provide lower-cost storage options, faster networking, and more geographically distributed caching of these assets making for a more efficient service for our users.

The following are some of the popular choices for asset storage.

  • Amazon Web Services S3
  • Azure
  • Google Cloud
  • Cloudinary

This lesson will be using Cloudinary since the integration is more straightforward and does not require a paid plan to get started. If you are interested in replacing Cloudinary with one of the other services, you may research existing dotnet libraries for those platforms.

Adding Cloudinary

First, we will sign up at Cloudinary for an API KEY. When you have your account created you'll need three values:

  • CLOUDINARY_CLOUD_NAME
  • CLOUDINARY_API_KEY
  • CLOUDINARY_API_SECRET

We will set all three in our secrets:

dotnet user-secrets set "CLOUDINARY_CLOUD_NAME" "REPLACE THIS"
dotnet user-secrets set "CLOUDINARY_API_KEY" "REPLACE THIS"
dotnet user-secrets set "CLOUDINARY_API_SECRET" "REPLACE THIS"

After securing these values, we will add the Cloudinary third-party package to our app:

dotnet add package CloudinaryDotNet

Creating a controller for uploading files

As we will consider uploads as a resource, we will create an UploadsController with a single POST endpoint for creating uploads.

using System.Collections.Generic;
using System.Net;
using CloudinaryDotNet;
using CloudinaryDotNet.Actions;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
namespace TacoTuesday.Controllers
{
// All of these routes will be at the base URL: /api/Uploads
// That is what "api/[controller]" means below. It uses the name of the controller
// in this case RestaurantsController to determine the URL
[Route("api/[controller]")]
[ApiController]
public class UploadsController : ControllerBase
{
private readonly string CLOUDINARY_CLOUD_NAME;
private readonly string CLOUDINARY_API_KEY;
private readonly string CLOUDINARY_API_SECRET;
// Constructor that receives a reference to your database context
// and stores it in _context_ for you to use in your API methods
public UploadsController(IConfiguration config)
{
CLOUDINARY_CLOUD_NAME = config["CLOUDINARY_CLOUD_NAME"];
CLOUDINARY_API_KEY = config["CLOUDINARY_API_KEY"];
CLOUDINARY_API_SECRET = config["CLOUDINARY_API_SECRET"];
}
// POST: api/Uploads
//
// Creates a new uploaded file
//
//
[HttpPost]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[RequestSizeLimit(10_000_000)]
public ActionResult Upload()
{
return Ok();
}
}
}

Here we have the shell of our controller. Notice we have not incorporated the DatabaseContext since this controller will not be doing anything with the database. It receives files from the user and uploads them to Cloudinary. Our constructor takes in the configuration and saves the Cloudinary configuration values we will need.

We have configured the endpoint only to allow authorized users to upload content.

We also set an upload size maximum to avoid allowing large images (and to ensure we can upload files at least that size) -- Cloudinary has a limit of about 10MB for uploads.

Changing the controller to accept a file upload

We will now update the controller definition to accept an upload, process it, send it to Cloudinary, and return the Cloudinary URL of the newly uploaded file.

The first thing we will do is update our endpoint to accept a file as input.

public ActionResult Upload(IFormFile file)

Next, we will ensure that we limit our uploads to supported image types.

To do so, we'll make a class property to hold a set of strings of the content types allowed. We will use the HashSet collection type since it is efficient for fast lookups and does not allow for duplicates (unlike a List)

private readonly HashSet<string> VALID_CONTENT_TYPES = new HashSet<string> {
"image/jpg",
"image/jpeg",
"image/pjpeg",
"image/gif",
"image/x-png",
"image/png",
};

Then we can add the following code at the beginning of our Upload method:

// Check this content type against a set of allowed content types
var contentType = file.ContentType.ToLower();
if (!VALID_CONTENT_TYPES.Contains(contentType))
{
// Return a 400 Bad Request when the content type is not allowed
return BadRequest("Not Valid Image");
}

After validating the content type we can proceed to send the content to Cloudinary

// Create and configure a client object to be able to upload to Cloudinary
var cloudinaryClient = new Cloudinary(new Account(CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET));
// Create an object describing the upload we are going to process.
// We will provide the file name and the stream of the content itself.
var uploadParams = new ImageUploadParams()
{
File = new FileDescription(file.FileName, file.OpenReadStream())
};
// Upload the file to the server
ImageUploadResult result = await cloudinaryClient.UploadLargeAsync(uploadParams);
// If the status code is an "OK" then the upload was accepted so we will return
// the URL to the client
if (result.StatusCode == HttpStatusCode.OK)
{
var urlOfUploadedFile = result.SecureUrl.AbsoluteUri;
return Ok(new { url = urlOfUploadedFile });
}
else
{
// Otherwise there was some failure in uploading
return BadRequest("Upload failed");
}

Adding the photo URL to the model

Now we can update the Restaurant.cs model to store a URL to the uploaded image.

public string PhotoURL { get; set; }
dotnet ef migrations add AddPhotoURLToRestaurant
dotnet ef database update

Update types

We'll now need to add photoURL to the RestaurantType and adjust any issues this introduces.

export type RestaurantType = {
id: string | undefined
name: string
description: string
address: string
telephone: string
latitude: number
longitude: number
photoURL: string
reviews: ReviewType[]
}

We'll also need a type for handining API results:

export type UploadResponse = {
url: string
}

Updating the user interface to upload a photo when creating a restaurant

To allow a user to upload a file to our restaurant, we'll use a fancy drag-and-drop library to create an interface for the upload.

cd ClientApp
npm install --save react-dropzone
cd ..

This adds a react component library that has great support for dragging and dropping files into our UI.

Then we will import this component on our NewRestaurant.tsx page:

import { useDropzone } from 'react-dropzone'

The dropzone component is expecting a method to call when a file is dropped onto a visible target in the UI. Let's add the method for that:

function onDropFile(acceptedFiles: File[]) {
// Do something with the files
const fileToUpload = acceptedFiles[0]
console.log(fileToUpload)
}

For now, we will have this log the details of the files dropped and get details of the first one. We are only going to allow single file drops for now.

Next we will get some configuration information from the dropzone library:

const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: onDropFile,
})

Replace the existing form input field for a photo with the following:

<div className="file-drop-zone">
<div {...getRootProps()}>
<input {...getInputProps()} />
{isDragActive
? 'Drop the files here ...'
: 'Drag a picture of the restaurant here to upload!'}
</div>
</div>

Try dragging and dropping a file on that part of the UI. When you drop a file you will see the details of your dropped file logged by the onDropFile method.

Next we will create a function to handle uploading of our file:

async function uploadFile(fileToUpload: File) {
// Create a formData object so we can send this
// to the API that is expecting some form data.
const formData = new FormData()
// Append a field that is the form upload itself
formData.append('file', fileToUpload)
// Use fetch to send an authorization header and
// a body containing the form data with the file
const response = await fetch('/api/Uploads', {
method: 'POST',
headers: {
Authorization: authHeader(),
},
body: formData,
})
if (response.ok) {
return response.json()
} else {
throw 'Unable to upload image!'
}
}

Then we will create a mutation for this function:

const uploadFileMutation = useMutation(uploadFile, {
onSuccess: function (apiResponse: UploadResponse) {
const url = apiResponse.url
setNewRestaurant({ ...newRestaurant, photoURL: url })
},
onError: function (error: string) {
setErrorMessage(error)
},
})

Now we can update onDropFile to process the upload.

async function onDropFile(acceptedFiles: File[]) {
// Do something with the files
const fileToUpload = acceptedFiles[0]
uploadFileMutation.mutate(fileToUpload)
}

Show a message during the upload process

Uploading a file can be slow, so let's add some status to the process. First, add a new state to indicate we are actively uploading a file:

const [isUploading, setIsUploading] = useState(false)

Then update our onDropFile to set the state to true when we start the upload and back to false when complete.

setIsUploading(true)

Then we will add an onSettled for our mutation. This will run regardless of success or failure.

onSettled: function () {
setIsUploading(false)
},

Then create a variable to hold the drop zone message:

let dropZoneMessage = 'Drag a picture of the restaurant here to upload!'
if (isUploading) {
dropZoneMessage = 'Uploading...'
}
if (isDragActive) {
dropZoneMessage = 'Drop the files here ...'
}

Replace the drop zone message itself:

<div className="file-drop-zone">
<div {...getRootProps()}>
<input {...getInputProps()} />
{dropZoneMessage}
</div>
</div>

Add an image preview on the NewRestaurant page

Add a preview of the image to the NewRestaurant component so the user can see the current upload.

Add this code just above the drop zone:

{
newRestaurant.photoURL ? (
<p>
<img alt="Restaurant Photo" width={200} src={newRestaurant.photoURL} />
</p>
) : null
}

Show the image on the Restaurant page

Update the Restaurant component to display the restaurant image, if present.

{
restaurant.photoURL ? (
<img alt="Restaurant Photo" width={200} src={restaurant.photoURL} />
) : null
}
© 2017 - 2022; Built with ♥ in St. Petersburg, Florida.