Skip to content

Instantly share code, notes, and snippets.

@JaimeStill
Last active November 11, 2022 14:21
Show Gist options
  • Save JaimeStill/21a8bb06242e4a418e047ae20d1de674 to your computer and use it in GitHub Desktop.
Save JaimeStill/21a8bb06242e4a418e047ae20d1de674 to your computer and use it in GitHub Desktop.
FormData Uploads in ASP.NET Core and Angular

Walkthrough Uploading FormData with Angular

For an example application that integrates this process, see my FullstackOverview repo on GitHub. The code in this example is taken directly from this app.

upload-demo

File Upload Component

This component allows you have a custom styled file upload element.

file-upload.component.ts

import {
  Component,
  EventEmitter,
  Input,
  Output,
  ViewChild,
  ElementRef
} from '@angular/core';

@Component({
  selector: 'file-upload',
  templateUrl: 'file-upload.component.html',
  styleUrls: ['file-upload.component.css']
})
export class FileUploadComponent {
  @ViewChild('fileInput') fileInput: ElementRef;
  @Input() accept = '*/*';
  @Input() color = 'primary';
  @Input() label = 'Browse...';
  @Input() multiple = true;
  @Output() selected = new EventEmitter<[File[], FormData]>();

  fileChange = (event: any) => {
    const files: FileList = event.target.files;
    const fileList = new Array<File>();
    const formData = new FormData();

    for (let i = 0; i < files.length; i++) {
      formData.append(files.item(i).name, files.item(i));
      fileList.push(files.item(i));
    }

    this.selected.emit([fileList, formData]);
    this.fileInput.nativeElement.value = null;
  }
}

file-upload.component.html

<input type="file"
       (change)="fileChange($event)"
       #fileInput
       [accept]="accept"
       [multiple]="multiple">
<button mat-button
        [color]="color"
        (click)="fileInput.click()">{{label}}</button>

file-upload.component.css

input[type=file] {
  display: none;
}

Usage in a State Component

This shows the pieces of a component (with everything else left out) that make use of the above component in order to enable file uploads.

uploads.component.html

<mat-toolbar>
  <span>Uploads</span>
  <section class="toolbar-buttons"
           [style.margin-left.px]="12">
    <file-upload (selected)="fileChange($event)"
                 accept="image/*"></file-upload>
    <button mat-button
            color="primary"
            (click)="uploadFiles()"
            *ngIf="formData"
            [disabled]="uploading">Upload</button>
    <button mat-button
            (click)="clearFiles()"
            *ngIf="formData"
            [disabled]="uploading">Cancel</button>
  </section>
</mat-toolbar>

uploads.component.ts

import { Component } from '@angular/core';

import {
  MatDialog
} from '@angular/material';

import {
  IdentityService,
  UploadService
} from '../../services';

import {
  Upload,
  User
} from '../../models';

@Component({
  selector: 'uploads',
  templateUrl: 'uploads.component.html',
  providers: [UploadService]
})
export class UploadsComponent {
  user: User;
  files: File[];
  formData: FormData;
  uploading = false;
  imgSize = 240;

  constructor(
    public identity: IdentityService,
    public upload: UploadService
  ) { }

  ngOnInit() {
    this.identity.identity$.subscribe(auth => {
      if (auth.user) {
        this.user = auth.user;
        this.upload.getUserUploads(this.user.id);
      }
    });
  }

  fileChange(fileDetails: [File[], FormData]) {
    this.files = fileDetails[0];
    this.formData = fileDetails[1];
  }

  clearFiles() {
    this.files = null;
    this.formData = null;
  }

  async uploadFiles() {
    this.uploading = true;
    const res = await this.upload.uploadFiles(this.formData, this.user.id);
    this.uploading = false;
    this.clearFiles();
    res && this.upload.getUserUploads(this.user.id);
  }
}

Upload Service

I have a CoreService Angular service that has a getUploadOptions() function that returns an HttpHeaders object that adjusts the headers for file uploads:

getUploadOptions = (): HttpHeaders => {
  const headers = new HttpHeaders();
  headers.set('Accept', 'application/json');
  headers.delete('Content-Type');
  return headers;
}

upload.service.ts

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject } from 'rxjs';
import { CoreService } from './core.service';
import { ObjectMapService } from './object-map.service';
import { SnackerService } from './snacker.service';
import { Upload } from '../models';

@Injectable()
export class UploadService {
  private uploads = new BehaviorSubject<Upload[]>(null);

  uploads$ = this.uploads.asObservable();

  constructor(
    private http: HttpClient,
    private core: CoreService,
    private snacker: SnackerService
  ) { }

  getUserUploads = (userId: number) =>
    this.http.get<Upload[]>(`/api/upload/getUserUploads/${userId}`)
      .subscribe(
        data => this.uploads.next(data),
        err => this.snacker.sendErrorMessage(err.error)
      );

  uploadFiles = (formData: FormData, userId: number): Promise<boolean> =>
    new Promise((resolve) => {
      this.http.post(
        `/api/upload/uploadFiles/${userId}`,
        formData,
        { headers: this.core.getUploadOptions() }
      )
      .subscribe(
        () => {
          this.snacker.sendSuccessMessage('Uploads successfully processed');
          resolve(true);
        },
        err => {
          this.snacker.sendErrorMessage(err.error);
          resolve(false);
        }
      )
    });
}

Startup Configuration

In this app, I've made the Directory the file will be uploaded to, as well as the URL base it will be referenced from, configurable via a class called UploadConfig:

UploadConfig.cs

public class UploadConfig
{
    public string DirectoryBasePath { get; set; }
    public string UrlBasePath { get; set; }
} 

It gets registered as a Singleton in Startup.cs ConfigureServices() method:

Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    // Additional Configuration

    if (Environment.IsDevelopment())
    {
        services.AddSingleton(new UploadConfig
        {
            DirectoryBasePath = $@"{Environment.ContentRootPath}\wwwroot\",
            UrlBasePath = "/"
        });
    }
    else
    {
        services.AddSingleton(new UploadConfig
        {
            DirectoryBasePath = Configuration.GetValue<string>("AppDirectoryBasePath"),
            UrlBasePath = Configuration.GetValue<string>("AppUrlBasePath")
        });
    }
    
    // Additional Configuration
}

Upload Controller

An API Controller receives the User ID of the current user in the URL path of an HTTP Post, and the uploads are retrieved from the FormData posted to the controller:

UploadController.cs

[Route("api/[controller]")]
public class UploadController : Controller
{
    public AppDbContext db;
    public UploadConfig config;

    public UploadController(AppDbContext db, UploadConfig config)
    {
        this.db = db;
        this.config = config;
    }

    [HttpPost("[action]/{id}")]
    [DisableRequestSizeLimit]
    public async Task<List<Upload>> UploadFiles([FromRoute]int id)
    {
        var files = Request.Form.Files;

        if (files.Count < 1)
        {
            throw new Exception("No files provided for upload");
        }

        return await db.UploadFiles(files, config.DirectoryBasePath, config.UrlBasePath, id);
    }
}

Uploading Files

The actual methods that comprise uploading files:

UploadExtensions.cs

public static async Task<List<Upload>> UploadFiles(this AppDbContext db, IFormFileCollection files, string path, string url, int userId)
{
    var uploads = new List<Upload>();

    foreach (var file in files)
    {
        uploads.Add(await db.AddUpload(file, path, url, userId));
    }

    return uploads;
}
        
static async Task<Upload> AddUpload(this AppDbContext db, IFormFile file, string path, string url, int userId)
{
    var upload = await file.WriteFile(path, url);
    upload.UserId = userId;
    upload.UploadDate = DateTime.Now;
    await db.Uploads.AddAsync(upload);
    await db.SaveChangesAsync();
    return upload;
}

static async Task<Upload> WriteFile(this IFormFile file, string path, string url)
{
    if (!(Directory.Exists(path)))
    {
        Directory.CreateDirectory(path);
    }

    var upload = await file.CreateUpload(path, url);

    using (var stream = new FileStream(upload.Path, FileMode.Create))
    {
        await file.CopyToAsync(stream);
    }

    return upload;
}

static Task<Upload> CreateUpload(this IFormFile file, string path, string url) => Task.Run(() =>
{
    var name = file.CreateSafeName(path);

    var upload = new Upload
    {
        File = name,
        Name = file.Name,
        Path = $"{path}{name}",
        Url = $"{url}{name}"
    };

    return upload;
});

static string CreateSafeName(this IFormFile file, string path)
{
    var increment = 0;
    var fileName = file.FileName.UrlEncode();
    var newName = fileName;

    while (File.Exists(path + newName))
    {
        var extension = fileName.Split('.').Last();
        newName = $"{fileName.Replace($".{extension}", "")}_{++increment}.{extension}";
    }

    return newName;
}
        
private static readonly string urlPattern = "[^a-zA-Z0-9-.]";

static string UrlEncode(this string url)
{
    var friendlyUrl = Regex.Replace(url, @"\s", "-").ToLower();
    friendlyUrl = Regex.Replace(friendlyUrl, urlPattern, string.Empty);
    return friendlyUrl;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment