Upload de imagens e geolocalização no AdonisJS

AdonisJS 17 de Jul de 2018

Esse post é a terceira parte da série de posts “Clone AirBnB com AdonisJS, React Native e ReactJS” onde iremos construir do zero uma aplicação tanto web quando mobile com dados servidos através de uma API RESTfeita com NodeJS utilizando o framework AdonisJS.

Chegaaaaamos na terceira e última parte de AdonisJS dessa série antes de partirmos para o ReactJS e React Native. Nesse post vamos terminar a API REST da nossa aplicação criando a parte de cadastro/edição de imóveis com upload de imagens e filtro por geolocalização (latitude/longitude).

Se você não seguiu as duas primeiras partes do post, fica tranquilo, o código construído até aqui está no Github, só clicar aqui.

Geolocalização

Pra começar, vamos pensar na usabilidade do usuário quando acessa nossa aplicação. O ideal é que assim que acesse o usuário veja em um mapa os imóveis mais próximos dele, vamos manter um valor padrão de 10km agora.

Para isso, precisamos adicionar uma nova regra de negócio ao nosso Model de Propery, mas por que no Model? Nesse caso, como vamos criar um filtro por distância que pode ser utilizado em mais locais do nosso código, podemos trabalhar com o conceito de Query Scopes do Adonis que permite criarmos nossos próprios métodos no construtor de queries.

Para isso, vamos acessar nosso arquivo app/models/Propery.js e no início da classe vamos criar nosso Query Scope com a seguinte sintaxe:

static scopeNearBy (query, latitude, longitude, distance) {
  return query;
}

Precisamos perceber duas coisas aqui:

  • Todo Query Scope é estático, ou seja, você não tem acesso ao this;
  • O nome do método deve iniciar com scope e seguir o padrão Camel Case;
  • O primeiro parâmetro sempre deve ser a query, nela temos acesso à construção de query atual e podemos adicionar where’s, limit, order, etc…

Antes de prosseguirmos, vamos importar a classe Database do Adonis antes da classe já que vamos utilizar um recurso dela logo logo.

const Database = use('Database')

Agora com o Scope criado, podemos adicionar algumas regras a ele:

static scopeNearBy (query, latitude, longitude, distance) {
  const haversine = `(6371 * acos(cos(radians(${latitude}))
    * cos(radians(latitude))
    * cos(radians(longitude)
    - radians(${longitude}))
    + sin(radians(${latitude}))
    * sin(radians(latitude))))`

  return query
    .select('*', Database.raw(`${haversine} as distance`))
    .whereRaw(`${haversine} < ${distance}`)
}

O código que acabamos de adicionar utiliza um cálculo naval de distância através de latitude e longitude conhecido como Haversine. Esse valor é multiplicado por 6371 que o transforma em quilômetros.

Logo após, adicionamos esse calculo ao SELECT do banco de dados além de retornar todos os outros campos com *. Ainda no fim, realizamos um whereRaw, que é utilizado quando não estamos utilizando as colunas comuns das tabelas e sim valores gerados por nós, para comparar a distância obtida através do Haversine com nossa variável distance que enviaremos depois para esse método.

Por mais que pareça complexo, esse método é muito comum e utilizado em todo tipo de aplicação com cálculo de distância por geolocalização.

Configurando controller

Até agora em nosso ProperyController nós estamos retornando TODOS os imóveis para os usuários, mas vamos alterar esse comportamento utilizado essa Scope Query que acabamos de criar para filtrar apenas os imóveis próximos alterando o método index.

async index ({ request }) {
  const { latitude, longitude } = request.all()

  const properties = Property.query()
    .nearBy(latitude, longitude, 10)
    .fetch()

  return properties
}

O que estamos fazendo aqui é buscar os dados de latitude e longitude do corpo da nossa requisição (que serão enviados através do front-end depois) e utilizando nosso método nearBy para buscar apenas imóveis com no máximo 10km de distância.

Para testarmos esse comportamento vamos criar um imóvel no banco de dados, para isso, crie um registro no banco de dados com os seguinte dados:

"title": "Rocketseat"
"address": "Rua teste, 260"
"longitude": -49.644018
"latitude" : -27.210768
"price": 100

Agora vamos acessar através da API a rota de listagem de imóveis passando uma latitude e longitude com uma pequena distância:

Teremos um resultado como o seguinte:

Veja que além dos campos comuns do imóvel temos agora também o campo distance e esse registro só retornou porque a distância é menor que 10km.

Criação/edição de imóveis

Agora que terminamos a parte de geolocalização precisamos criar os métodos de cadastro e edição de imóveis no controller. Mais do que isso, precisamos permitir que o usuário envie imagens do imóvel para cadastrar na tabela images que criamos no post anterior.

Vamos começar com a parte simples de criação e edição, para isso, vamos editar o método store do nosso PropertyController para o seguinte:

async store ({ auth, request, response }) {
  const { id } = auth.user
  const data = request.only([
    'title',
    'address',
    'latitude',
    'longitude',
    'price'
  ])

  const property = await Property.create({ ...data, user_id: id })

  return property
}

Não tem muito mistério, buscamos o ID do usuário logado através do objeto auth embutido automaticamente em todos métodos dos controllers, e também os campos da requisição para criação do imóvel. Logo após, criamos o imóvel com o método create utilizando todos campos da requisição mais o ID do usuário.

Agora, voltamos ao Insomnia para criar a requisição:

Agora, executando-a temos o seguinte resultado:

Agora que já conseguimos criar registros no banco de dados, vamos alterar o método update do controller para fazer a parte de edição:

async update ({ params, request, response }) {
  const property = await Property.findOrFail(params.id)

  const data = request.only([
    'title',
    'address',
    'latitude',
    'longitude',
    'price'
  ])

  property.merge(data)

  await property.save()

  return property
}

A principal diferença desse método para o create é que precisamos buscar o ID que vem na URL para buscar um imóvel que já existe e atualiza-lo. Para isso utilizamos o método findOrFail que retornará erro caso o imóvel não existe.

Logo após, buscamos novamente os dados da requisição e realizamos um merge no registro salvando os novos dados.

Podemos novamente criar um método no Insomnia para testar essa requisição:

Executando essa requisição, temos o seguinte resultado:

Upload de imagens no AdonisJS

No caso dos uploads para API REST o fluxo é um pouco diferente do upload comum através de formulário. Nesse caso, quando fizermos o front-end precisaremos enviar os dados das imagens através do FormData, mas como eu não quero ter que enviar todos os dados do imóvel dessa forma, vou separar a parte de upload em outro controller.

Para isso, vamos criar um novo controller chamado ImageController com um único método store:

'use strict'

const Image = use('App/Models/Image')
const Property = use('App/Models/Property')

/**
 * Resourceful controller for interacting with images
 */
class ImageController {
  /**
   * Create/save a new image.
   * POST images
   */
  async store ({ request }) {
    
  }
}

module.exports = ImageController

Agora, vamos criar a rota para acessar esse método no controller para adicionar novas imagens ao imóvel. Para isso no arquivo app/routes.js adicione:

Route.post('properties/:id/images', 'ImageController.store')
  .middleware('auth')

Veja que estou utilizando uma rota encadeada, ou seja, como a imagem sempre precisa do ID do imóvel associado a ela, podemos utilizar a rota que já temos de imóveis seguida da palavra images.

Agora no controller de imagens vamos começar importando no início do arquivo a classe Helpers do Adonis que vai nos dar acesso ao caminho da pasta de uploads chamada tmp.

const Helpers = use('Helpers')

E agora em nosso método store vamos começar buscando o imóvel pelo ID recebido na URL assim como fizemos na edição e buscando também as imagens da requisição:

async store ({ params, request }) {
  const property = await Property.findOrFail(params.id)

  const images = request.file('image', {
    types: ['image'],
    size: '2mb'
  })
}

O que aprendemos de novo até aqui foi o request.file que nos trás um ou mais arquivos com o nome do primeiro parâmetro e ainda podemos limitar para arquivos apenas do tipo de imagem com tamanho até 2mb.

Agora, com essas imagens, precisamos salvá-las em uma pasta de uploads para termos acesso posterior:

await images.moveAll(Helpers.tmpPath('uploads'), file => ({
  name: `${Date.now()}-${file.clientName}`
}))

if (!images.movedAll()) {
  return images.errors()
}

Veja que aqui estou movendo TODAS imagens para uma pasta tmp/uploads no Adonis e para cada arquivo estou alterando o nome do mesmo com o timestamp atual evitando arquivos duplicados.

Caso aconteça qualquer erro no upload, o processo para por aí e nosso front-end fica sabendo dos problemas.

Agora que já temos os arquivos vamos criar os registros de imagens no banco de dados associados com o imóvel:

await Promise.all(
  images
    .movedList()
    .map(image => property.images().create({ path: image.fileName }))
)

Com esse código estamos percorrendo todas imagens salvas e cadastrando dentro do imóvel, isso só é possível pois dentro do nosso model de imóvel temos um método images() que é o relacionamento de imóvel com imagens.

Veja que utilizo a técnica de await Promise.all pois temos uma iteração (map) assíncrona, ou seja, cada create que estamos dando retorna uma Promise, ou seja, pode demorar, com o await evitamos que nossa requisição retorne sucesso antes mesmo de terminar o processo.

Nosso método deve ficar assim no fim:

async store ({ params, request }) {
  const property = await Property.findOrFail(params.id)

  const images = request.file('image', {
    types: ['image'],
    size: '2mb'
  })

  await images.moveAll(Helpers.tmpPath('uploads'), file => ({
    name: `${Date.now()}-${file.clientName}`
  }))

  if (!images.movedAll()) {
    return images.errors()
  }

  await Promise.all(
    images
      .movedList()
      .map(image => property.images().create({ path: image.fileName }))
  )
}

Agora, para testarmos o upload vamos criar um novo método no Insomnia e diferente dos outros até agora vamos utilizar o formato do corpo Multipart Form enviando duas imagens:

Enviando a requisição vamos ter apenas uma resposta de sucesso sem corpo já que não retornamos nada. Nós vamos mostrar as imagens do imóvel no método show e index do imóvel, por isso, no PropertyController vamos alterar a query do método index para:

const properties = Property.query()
  .with('images')
  .nearBy(latitude, longitude, 10)
  .fetch()

Veja que faço o uso do .with que realiza um processo de Eager Loading nas imagens adicionando-as ao retorno de cada imóvel. Testando a requisição de listar imóveis no Insomnia agora temos:

Pronto, no método show já havíamos criado anteriormente esse comportamento com a linha await property.load('images'), nesse caso a sintaxe é diferente pois realizamos um find ao invés de uma query.

O último ponto que vamos fazer é criar um campo “virtual” no nosso model de imagem para retornar o caminho completo da imagem para nosso front-end, para isso, no arquivo app/Models/Image.js altere seu conteúdo para:

'use strict'

const Env = use('Env')
const Model = use('Model')

class Image extends Model {
  static get computed () {
    return ['url']
  }

  getUrl ({ path }) {
    return `${Env.get('APP_URL')}/images/${path}`
  }
}

module.exports = Image

Veja que criamos um campo “computed” chamado url e adicionamos seu valor com um método com prefixo get seguido do campo em camel case. Utilizamos também o Env para recuperar a URL da nossa API.

Se tentarmos acessar esse link ainda não temos uma rota para exibir a imagem, por isso vamos criar uma nova rota que apenas mostra a imagem. Para isso no app/routes.js adicione:

Route.get('images/:path', 'ImageController.show')

Agora no ImageController adicione um método show com o código:

Pronto! Agora se você acessar o campo URL que é retornando na imagem poderá vê-la pelo navegador, e isso faz com que nosso front-end tenha acesso ao mesmo comportamento 🙂

Ufa… acabou

Agora sim, nossa API ficou garbosa com todas essas adições e já temos muitas funcionalidades lidando com relacionamentos, uploads, geolocalização, rotas, etc…

Para essa série terminamos a parte de Adonis lembrando que ainda vamos continuar essa aplicação no front-end consumindo essa API e criando todo seu funcionamento com ReactJS e React Native para web/mobile.

Código desse post: https://github.com/Rocketseat/blog-adonis-reactjs-react-native-airbnb/tree/upload-geo

Ah, não esquece que se tiver gostando dessa série deixa seu comentário aí em baixo que faz toda diferença!! 🙂

Marcadores

Diego Fernandes

Programador full-stack, apaixonado pelas melhores tecnologias de desenvolvimento back-end, front-end e mobile, é co-fundador e CTO na Rocketseat.