/ angular

Uploading files in Angular (2/4) to a REST api

I haven't written any Angular(2(/4)(+)(wtf?)) posts so far - but since I've been working with Angular (okay, let's just call it Angular) for some time now let's do so.

An interesting thing about Angular is that uploading files is barely handled by anyone. It's not mentioned in the docs and the first thing you find by using Google is a third party lib, ng2-file-upload.

But what I needed was a simple FileInput field within a form, along with other input fields, which can be send to a REST api. Think of updating your profile, including the possibility of updating your avatar.
Speaking in REST this would lead to a payload something like:

POST /api/user
{
    "name": "Bob the Builder",
    "avatar": <... yeah, what's here? ...>
}

There are actually different solutions for this and I'd like to cover two of them here, since I ended up using both approaches.

Prerequisites

Before we're diving right into the solutions we're going to need a few things before.

For this example I'll use Angular CLI (since I haven't had the opportunity to play around with it so far) which currently uses Angular 4.2.4. But this should work in Angular 2.x.x as well as in other versions.

You can find the entire source code for this entire project at GitHub.

First of all we're going to need a model for our user:

// src/app/shared/models/User.ts

export class User {
    id: number;
    avatar: string|any;
}

This object will held our (client-side) user object and will be used in both approaches.

Base64 encoding

The solution I've used for "smaller" things (like changing an avatar or similar) is to encode the file client-side, send the encoded string to the server and finally decode and save it there.

Let's create a very simple component for this uploading method called Base64UploadComponent.

First of all we're going to need an HTML template:

// src/app/base64-upload/base64-upload.component.html

<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <div class="form-group">
    <label for="name">Name</label>
    <input type="text" class="form-control" id="name" placeholder="Bob" formControlName="name">
  </div>
  <div class="form-group">
    <label for="avatar">Avatar</label>
    <input type="file" id="avatar" (change)="onFileChange($event)" #fileInput>
    <button type="button" class="btn btn-sm btn-default" (click)="clearFile()">clear file</button>
  </div>
  <button type="submit" [disabled]="form.invalid || loading" class="btn btn-success">Submit <i class="fa fa-spinner fa-spin fa-fw" *ngIf="loading"></i></button>
</form>

There's no magic in this file, but if you haven't read the article about Reactive Forms you should probably do that before. The really interesting part here is our file input:

<input type="file" id="avatar" (change)="onFileChange($event)" #fileInput>

We're attaching a change listener here (onFileChange) here which will handle what happens if you select a file to upload. This is actually a really simple function:

  onFileChange(event) {
    let reader = new FileReader();
    if(event.target.files && event.target.files.length > 0) {
      let file = event.target.files[0];
      reader.readAsDataURL(file);
      reader.onload = () => {
        this.form.get('avatar').setValue({
          filename: file.name,
          filetype: file.type,
          value: reader.result.split(',')[1]
        })
      };
    }
  }

The FileReader is responsible for reading our file contents. As soon we've selected a file we're setting our form value for our avatar to an object which contains the filename, the filetype and the value (which is the base64 encoded string!).

And, well, that's it. When submitting our form our avatar form control now helds the information about the avatar, which can be decoded and saved on our backend.

The entire component

For simplicity here's the entire component:

// src/app/formdata-upload/base64-upload.component.ts

import {Component, ElementRef, ViewChild} from '@angular/core';
import {FormBuilder, FormGroup, Validators} from "@angular/forms";

@Component({
  selector: 'base64-upload',
  templateUrl: './base64-upload.component.html'
})
export class Base64UploadComponent {
  form: FormGroup;
  loading: boolean = false;

  @ViewChild('fileInput') fileInput: ElementRef;

  constructor(private fb: FormBuilder) {
    this.createForm();
  }

  createForm() {
    this.form = this.fb.group({
      name: ['', Validators.required],
      avatar: null
    });
  }

  onFileChange(event) {
    let reader = new FileReader();
    if(event.target.files && event.target.files.length > 0) {
      let file = event.target.files[0];
      reader.readAsDataURL(file);
      reader.onload = () => {
        this.form.get('avatar').setValue({
          filename: file.name,
          filetype: file.type,
          value: reader.result.split(',')[1]
        })
      };
    }
  }

  onSubmit() {
    const formModel = this.form.value;
    this.loading = true;
    // In a real-world app you'd have a http request / service call here like
    // this.http.post('apiUrl', formModel)
    setTimeout(() => {
      console.log(formModel);
      alert('done!');
      this.loading = false;
    }, 1000);
  }

  clearFile() {
    this.form.get('avatar').setValue(null);
    this.fileInput.nativeElement.value = '';
  }
}

Final payload

Our payload (aka the formModel) should now look something like this:

{
  name: "John",
  avatar: {
    filename: "10x10png",
    filetype: "image/png",
    value: "iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKBAMAAAB/HNKOAAAAGFBMVEXMzMyWlpajo6O3t7fFxcWcnJyxsbG+vr50Rsl6AAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAJklEQVQImWNgwADKDAwsAQyuDAzMAgyMbOYMAgyuLApAUhnMRgIANvcCBwsFJwYAAAAASUVORK5CYII="
  }
}

Saving files (with PHP)

In case you're using PHP you'll need something like this to save the file on your server:

file_put_contents($payload['avatar']['filename'], base64_decode($payload['avatar']['value']));

Downsides

This approach has one downside: the payload gets bigger. Depending on the image it can get really big. There's an interesting article by David Calhoun about when to Base64 encode images (and when not to).

Using FormData

Another approach is using FormData. This makes it possible to send binaries - which is good. But it's slightly more awkward to implement as you'll see.

For this we're creating a FormdataUploadComponent which uses the exact same template as above:

// src/app/formdata-upload/formdata-upload.component.html

<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <div class="form-group">
    <label for="name">Name</label>
    <input type="text" class="form-control" id="name" placeholder="Bob" formControlName="name">
  </div>
  <div class="form-group">
    <label for="avatar">Avatar</label>
    <input type="file" id="avatar" (change)="onFileChange($event)" #fileInput>
    <button type="button" class="btn btn-sm btn-default" (click)="clearFile()">clear file</button>
  </div>
  <button type="submit" [disabled]="form.invalid || loading" class="btn btn-success">Submit <i class="fa fa-spinner fa-spin fa-fw" *ngIf="loading"></i></button>
</form>

Our change listener is now slightly different:

  onFileChange(event) {
    if(event.target.files.length > 0) {
      let file = event.target.files[0];
      this.form.get('avatar').setValue(file);
    }
  }

We're just assigning the value of the uploaded file to our avatar form value here. But before sending our form we need to create a FormData instance of our form which will be send to our server:

  private prepareSave(): any {
    let input = new FormData();
    // This can be done a lot prettier; for example automatically assigning values by looping through `this.form.controls`, but we'll keep it as simple as possible here
    input.append('name', this.form.get('name').value);
    input.append('avatar', this.form.get('avatar').value);
    return input;
  }

  onSubmit() {
    const formModel = this.prepareSave();
    this.loading = true;
    // In a real-world app you'd have a http request / service call here like
    // this.http.post('apiUrl', formModel)
    setTimeout(() => {
      // FormData cannot be inspected (see "Key difference"), hence no need to log it here
      alert('done!');
      this.loading = false;
    }, 1000);
  }

And... done!

The entire component

Again, here's the entire component:

// src/app/formdata-upload/formdata-upload.component.ts

import {Component, ElementRef, ViewChild} from '@angular/core';
import {FormBuilder, FormGroup, Validators} from "@angular/forms";

@Component({
  selector: 'formdata-upload',
  templateUrl: './formdata-upload.component.html'
})
export class FormdataUploadComponent {
  form: FormGroup;
  loading: boolean = false;

  @ViewChild('fileInput') fileInput: ElementRef;

  constructor(private fb: FormBuilder) {
    this.createForm();
  }

  createForm() {
    this.form = this.fb.group({
      name: ['', Validators.required],
      avatar: null
    });
  }

  onFileChange(event) {
    if(event.target.files.length > 0) {
      let file = event.target.files[0];
      this.form.get('avatar').setValue(file);
    }
  }

  private prepareSave(): any {
    let input = new FormData();
    input.append('name', this.form.get('name').value);
    input.append('avatar', this.form.get('avatar').value);
    return input;
  }

  onSubmit() {
    const formModel = this.prepareSave();
    this.loading = true;
    // In a real-world app you'd have a http request / service call here like
    // this.http.post('apiUrl', formModel)
    setTimeout(() => {
      // FormData cannot be inspected (see "Key difference"), hence no need to log it here
      alert('done!');
      this.loading = false;
    }, 1000);
  }

  clearFile() {
    this.form.get('avatar').setValue(null);
    this.fileInput.nativeElement.value = '';
  }
}

Key difference

Keep in mind that with this method our form is now part of our request and no longer part of our content. Speaking in PHP again (more specifically in Symfonys HttpFoundation\Request (which is also part of Laravel)) this would mean that the values for our form are now accessed by

$avatar = $request->request->get('avatar');

instead of

$avatar = $request->getContent()['avatar'];

Main advantage of this method is that the file is send as binary, which saves a lot of space.

Kevin

Kevin

I make stuff. Mostly functional, occasionally shiny, stuff.

Read More