Cargar archivos a Google Drive con Phoenix LiveView
Tags: elixir, phoenix, liveview, javascript
Published: 2021-04-19 21:37:17

Phoenix LiveView tiene una gran abstracción a la hora de realizar subida de archivos. Nos facilita bastante la vida tanto desde el lado del cliente (interfaz de usuario) como desde el lado del servidor (nuestro servidor o incluso a servicios de terceros como servicios en la nube).

Este post está enfocado en ayudarte con la subida de archivos al servicio de Google Drive, ya que muchas veces los servicios de Google son difíciles de entender y lleva tiempo el poder descubrir la manera de hacer una integración exitosa, como lo fue mi caso. Es por esto que quisiera compartir con la comunidad la manera de como logré hacerlo después de mucho tiempo de búsqueda y ensayos.

### Comencemos...

![Formulario de subida de archivos](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5n7n9ykkr4oxnq37mv7e.png)

Esta guía no pretende mostrar el detalle de cómo funciona el proceso de subida de archivos en Phoenix LiveView. Mejor, pretende mostrar la integración de Google Drive con lo que ya las guías [External Uploads] (https://hexdocs.pm/phoenix_live_view/uploads-external.html) de Phoenix LiveView y [Phoenix LiveView Uploads Deep Dive](https://www.phoenixframework.org/blog/phoenix-live-view-upload-deep-dive) por [Chris McCord] (https://twitter.com/chris_mccord) muestran de una manera super clara y fácil de entender.

Para conocer el detalle de cómo funciona el proceso de subida de archivos en Phoenix LiveView puedes mirar las guías anteriormente mencionadas.

#### Requerimientos previos
 
Lo primero que debemos tener en cuenta es que debemos habilitar el acceso a la API de Google Drive, esto lo puedes lograr visitando la documentación de [Google Drive API](https://developers.google.com/drive/api/v3/about-sdk).

Asegúrate de crear una Cuenta de Servicio en [Google Cloud] (https://console.cloud.google.com/iam-admin/serviceaccounts) y por último tener el archivo **.json** con las credenciales de tu Cuenta de Servicio de Google Cloud. Este archivo debe contener algo parecido a esto:

```json
{
  "type": "service_account",
  "project_id": "<your google cloud project>",
  "private_key_id": "<your private key id>",
  "private_key": "<your private key>",
  "client_email": "<your client email>",
  "client_id": "<your client id>",
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  "token_uri": "https://oauth2.googleapis.com/token",
  "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
  "client_x509_cert_url": "<your client x509 cert url>"
}
```

#### Configuración de dependencias
Para poder realizar la autenticación con los servicios de Google usaremos la librería [Goth](https://hexdocs.pm/goth/api-reference.html) (Google + Auth).

Añadimos Goth a nuestras dependencias en el archivo **mix.exs**
```elixir
  defp deps do
    [
      ...
      {:goth, "~> 1.2.0"}
    ]
  end
```

Corremos la siguiente línea en la consola para descargar nuestras dependencias:
> $ mix deps.get

Y por último, debemos exponer una variable de ambiente llamada **GOOGLE_APPLICATION_CREDENTIALS** con la cual Goth tendría lo necesario para obtener un *access token*:
> $ export GOOGLE_APPLICATION_CREDENTIALS=\<your_service_account_file>\.json

Donde \<your_service_account_file> es el path al archivo **.json** que contiene las credenciales de tu Cuenta de Servicio de Google Cloud.

(Este archivo es sencible, no lo deberías añadir en tu repositorio de código)

### Vamos al código...
#### Del lado de nuestra LiveView
En nuestro archivo de LiveView debemos habilitar la carga de archivos. Para ello en la función **mount**, modificamos:

```elixir
  def mount(socket) do
    {:ok,
     allow_upload(
       socket,
       :photos,
       accept: ~w(.png .jpeg .jpg),
       max_entries: 2,
       external: &presign_entry/2
     )}
  end
```

La propiedad *accept* habilita la subida de archivos aceptando únicamente formatos **.png**, **.jpeg** o **.jpg**, en este caso. La propiedad *max_entries* permite la subida de máximo dos (2) archivos.

La propiedad *external* debe ser una función callback con dos parámetros. Esta función será en realidad la encargada de llamar a la función que sube los archivos a Google Drive, pasándole los datos necesarios para realizar la subida. Cuando usamos *external* es porque la función que se encargará de subir los archivos será una función de JavaScript (en el lado del cliente). Lo haremos con JavaScript, ya que más adelante queremos saber el progreso de subida de cada uno de los archivos, saber si hay un error en este proceso de subida, o si hay un error en la validación de los archivos. Todo esto usando *XMLHttpRequest* de JavaScript.

Añadimos nuestra función **presign_entry** en nuestro archivo de LiveView:

```elixir
  @google_drive_url "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart"
  @google_drive_scope "https://www.googleapis.com/auth/drive.file"
  defp presign_entry(entry, socket) do
    {:ok, %{token: token}} = Goth.Token.for_scope(@google_drive_scope)

    fields = %{
      name: "#{entry.uuid}.#{ext(entry)}",
      content_type: entry.client_type,
      token: token
    }

    {:ok, %{uploader: "GoogleDriveMultipart", url: @google_drive_url, fields: fields}, socket}
  end

  defp ext(entry) do
    [ext | _] = MIME.extensions(entry.client_type)
    ext
  end
```

Añadimos la función callback **handle_event** para manejar el evento de cancelar la subida de alguno de los archivos:

```elixir
def handle_event("cancel-entry", %{"ref" => ref}, socket) do
  {:noreply, cancel_upload(socket, :photos, ref)}
end
```

Añadimos los componentes Phoenix HTML para la subida de los archivos:

```elixir
    ...
    <%= live_file_input @uploads.photos %>

    <%= for {_ref, msg} <- @uploads.photos.errors do %>
      <p class="alert alert-danger">
        <%= Phoenix.Naming.humanize(msg) %>
      </p>
    <% end %>

    <%= for entry <- @uploads.photos.entries do %>
      <%= live_img_preview(entry) %>
      <progress max="100" value="<%= entry.progress %>" />
      <a
        href="#"
        phx-click="cancel-entry"
        phx-value-ref="<%= entry.ref %>"
      >
        Cancel
      </a>
    <% end %>
```

#### Del lado de JavaScript

En el archivo **app.js** habilitamos la carga externa de archivos añadiendo lo siguiente:

```javascript
import { uploadPhotosToGoogleDrive } from "./uploaders/google-drive"

const Uploaders = {
  GoogleDriveMultipart: uploadPhotosToGoogleDrive
}

let liveSocket = new LiveSocket("/live", Socket, {
  uploaders: Uploaders,
  params: { _csrf_token: csrfToken }
})
```

En la carpeta *uploaders* creamos el archivo **google-drive.js** añadiendo lo siguiente:

```javascript
const createRequestPayload = (fields, photo) => {
  const boundary = 'uploading photos'
  const multipartRequestHeaders = [
    ['Content-Type', `multipart/related; boundary="${boundary}"`],
    ['Authorization', `Bearer ${fields.token}`]
  ]
  const delimiter = "\r\n--" + boundary + "\r\n"
  const close_delim = "\r\n--" + boundary + "--"
  const contentType = fields.content_type
  const metadata = {
    'name': fields.name,
    'mimeType': contentType,
    'parents': [fields.parent]
  }

  const base64Data = btoa(photo)
  const multipartRequestBody =
    delimiter +
    'Content-Type: application/json; charset=UTF-8\r\n\r\n' +
    JSON.stringify(metadata) +
    delimiter +
    'Content-Type: ' + contentType + '\r\n' +
    'Content-Transfer-Encoding: base64\r\n' +
    '\r\n' +
    base64Data +
    close_delim

  return {
    multipartRequestHeaders,
    multipartRequestBody
  }
}

export const uploadPhotosToGoogleDrive = (entries, onViewError) => {
  entries.forEach(entry => {
    const { file, meta: { url, fields } } = entry

    const reader = new FileReader()

    reader.readAsBinaryString(file)
    reader.onload = () => {
      const {
        multipartRequestHeaders,
        multipartRequestBody
      } = createRequestPayload(fields, reader.result)

      const xhr = new XMLHttpRequest()
      onViewError(() => xhr.abort())

      xhr.onprogress = event => {
        if (event.lengthComputable) {
          const percent = Math.round((event.loaded / event.total) * 100)
          entry.progress(percent)
        }
      }

      xhr.open("POST", url, true)
      multipartRequestHeaders.map(([key, value]) => {
        xhr.setRequestHeader(key, value)
      })

      xhr.send(multipartRequestBody)

      xhr.onload = () => {
        if (xhr.status !== 200) {
          return entry.error()
        }
      }
      xhr.onerror = () => entry.error()
    }
  })
}
```

### Eso es todo! Probemos...

Al probar la carga de archivos podremos ver cómo el progreso de subida muestra la barra al 100% completa (en color verde).
![Archivo cargado existosamente](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sdbumwf3mkzvcg9z1hzh.png)

Y por último, en los DevTools podremos ver una respuesta exitosa obtenida por Google Drive API de la cual podremos saber el tipo de subida, el ID del archivo dentro de Google Drive, el nombre y el formato de este.
![Response de Google Drive](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5kv594f7hbl4dhd06om4.png)

## Y listo, tienes tu archivo en Google Drive!

---
Para ver la implementación completa puedes visitar el repo:
{% github santiagocardo/car-workshop %}