Skip to content

IronPTSolutions/geo-api

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

geo-api — Geolocation con Mongoose

API REST construida con Node.js, Express y MongoDB para aprender a trabajar con datos de geolocalización usando Mongoose y las capacidades espaciales de MongoDB.

Este proyecto sirve como referencia para entender cómo almacenar coordenadas geográficas en una base de datos y cómo hacer consultas de proximidad del tipo "restaurantes cerca de mí" o "restaurantes dentro de un radio".


Indice


Conceptos clave: GeoJSON en MongoDB

GeoJSON es un formato estándar para representar datos geográficos en JSON. MongoDB lo soporta de forma nativa, lo que nos permite guardar coordenadas y hacer consultas espaciales directamente desde Mongoose.

El tipo más común es Point (un punto en el mapa), que se define así:

{
  "type": "Point",
  "coordinates": [-3.7038, 40.4168]
}

Orden de las coordenadas: LONGITUD primero, LATITUD segundo

Este es el detalle mas importante (y el error mas frecuente):

coordinates: [longitud, latitud]
  • Longitud = eje X (cuanto hacia el este u oeste). Madrid: -3.7038
  • Latitud = eje Y (cuanto hacia el norte o sur). Madrid: 40.4168

En el dia a dia estamos acostumbrados a decir "latitud, longitud" (como en Google Maps), pero MongoDB usa el orden matematico inverso: longitud primero. Si lo confundes, tus puntos apareceran en el oceano.


Definir un campo GeoJSON en Mongoose

Asi se define un campo de tipo GeoJSON Point en un schema de Mongoose (/models/restaurant.model.js):

const { Schema, model } = require('mongoose')

const restaurantSchema = new Schema(
  {
    name: {
      type: String,
      required: [true, 'El nombre del restaurante es obligatorio'],
      trim: true,
    },
    cuisine: {
      type: String,
      required: [true, 'El tipo de cocina es obligatorio'],
      trim: true,
    },
    address: {
      type: String,
      trim: true,
    },
    // Campo GeoJSON Point para almacenar la ubicacion del restaurante
    location: {
      type: {
        type: String,
        enum: ['Point'], // Solo permitimos el tipo "Point"
        required: true,
      },
      coordinates: {
        type: [Number], // Array de numeros: [longitud, latitud]
        required: true,
      },
    },
  },
  {
    timestamps: true,
  }
)

// El indice 2dsphere es OBLIGATORIO para que las consultas geo funcionen
restaurantSchema.index({ location: '2dsphere' })

const Restaurant = model('Restaurant', restaurantSchema)

module.exports = Restaurant

Puntos clave del schema:

  • El campo type dentro de location debe ser siempre 'Point' (usamos enum para forzarlo).
  • coordinates es un array de dos numeros: [longitud, latitud].
  • La linea restaurantSchema.index({ location: '2dsphere' }) es imprescindible — la explicamos en la siguiente seccion.

El indice 2dsphere

MongoDB necesita un indice especial llamado 2dsphere para poder ejecutar consultas geograficas. Sin el, cualquier consulta con $near o $geoWithin fallara con un error.

restaurantSchema.index({ location: '2dsphere' })

Por que es necesario?

Las consultas geograficas son computacionalmente costosas: MongoDB tiene que calcular distancias entre puntos usando formulas trigonometricas sobre una esfera (la Tierra). El indice 2dsphere pre-organiza los datos en una estructura espacial (similar a como un indice normal organiza texto) para que estas consultas sean eficientes.

Sin el indice, MongoDB tendria que recorrer TODOS los documentos de la coleccion para calcular la distancia a cada uno. Con millones de restaurantes, eso seria inviable.

El indice 2dsphere le dice a MongoDB: "Este campo contiene coordenadas geograficas sobre una esfera. Organizalos de forma que pueda encontrar puntos cercanos rapidamente."


Consulta $near

$near busca documentos ordenados por distancia (del mas cercano al mas lejano) desde un punto de referencia.

Como funciona

Restaurant.find({
  location: {
    $near: {
      $geometry: {
        type: 'Point',
        coordinates: [longitud, latitud], // Punto de referencia
      },
      $maxDistance: 1000, // Radio maximo en METROS
    },
  },
})
  • $geometry: el punto desde el que mides la distancia (en formato GeoJSON).
  • $maxDistance: distancia maxima en metros. Si lo omites, devuelve todos los documentos ordenados por distancia (puede ser muy lento).
  • Los resultados vienen ordenados automaticamente de mas cercano a mas lejano.

Ejemplo en el controlador

// En restaurants.controller.js
const { lat, lng, maxDistance } = req.query

const filter = {}

if (lat && lng && maxDistance) {
  filter.location = {
    $near: {
      $geometry: {
        type: 'Point',
        coordinates: [parseFloat(lng), parseFloat(lat)], // OJO: lng primero
      },
      $maxDistance: parseFloat(maxDistance),
    },
  }
}

const restaurants = await Restaurant.find(filter)

Ejemplo con curl

Buscar restaurantes a menos de 1km del centro de Madrid (Puerta del Sol):

curl "http://localhost:5005/api/restaurants?lat=40.4168&lng=-3.7038&maxDistance=1000"

Consulta $geoWithin + $centerSphere

$geoWithin busca documentos cuya ubicacion este dentro de una forma geometrica. Combinado con $centerSphere, define un circulo sobre la esfera terrestre.

Diferencia clave con $near

Caracteristica $near $geoWithin
Ordena por distancia Si No
Requiere indice 2dsphere Si No (pero es recomendable)
Uso en aggregation No Si
Resultado Mas cercano primero Sin orden garantizado

Como funciona

Restaurant.find({
  location: {
    $geoWithin: {
      $centerSphere: [
        [longitud, latitud], // Centro del circulo
        radio_en_radianes,   // Radio en RADIANES (no metros)
      ],
    },
  },
})

La conversion de metros a radianes

Este es el punto donde muchos estudiantes se confunden. $centerSphere espera el radio en radianes, no en metros.

La formula es:

radianes = metros / 6378100

Donde 6378100 es el radio de la Tierra en metros.

const radiusInRadians = parseFloat(radius) / 6378100

Por ejemplo, para un radio de 2km (2000 metros):

2000 / 6378100 ≈ 0.000313706 radianes

Ejemplo en el controlador

// En restaurants.controller.js
const { lat, lng, radius } = req.query

const filter = {}

if (lat && lng && radius) {
  const radiusInRadians = parseFloat(radius) / 6378100 // Convertir metros a radianes

  filter.location = {
    $geoWithin: {
      $centerSphere: [
        [parseFloat(lng), parseFloat(lat)], // OJO: lng primero
        radiusInRadians,
      ],
    },
  }
}

const restaurants = await Restaurant.find(filter)

Ejemplo con curl

Buscar restaurantes dentro de un radio de 2km del centro de Madrid:

curl "http://localhost:5005/api/restaurants?lat=40.4168&lng=-3.7038&radius=2000"

Combinar con filtro de cocina

Todos los filtros son combinables. Por ejemplo, restaurantes italianos dentro de 3km:

curl "http://localhost:5005/api/restaurants?lat=40.4168&lng=-3.7038&radius=3000&cuisine=Italian"

Errores comunes

1. Orden incorrecto de coordenadas (el error #1)

Problema: Poner latitud antes que longitud.

// MAL - latitud, longitud (orden de Google Maps)
coordinates: [40.4168, -3.7038]

// BIEN - longitud, latitud (orden de MongoDB/GeoJSON)
coordinates: [-3.7038, 40.4168]

Como detectarlo: Tus restaurantes apareceran ubicados en el mar o en lugares imposibles (p.ej. en el Oceano Atlantico en lugar de en Madrid).


2. Olvidar el indice 2dsphere

Problema: No definir el indice en el schema.

Error que veras en la consola:

MongoServerError: error processing query: ns=geo-api.restaurants
Tree: GEONEAR  field=location maxdist=1000 isNearSphere=0
planner returned error :: caused by :: unable to find index for $geoNear query

Solucion: Anadir la linea del indice en el schema:

restaurantSchema.index({ location: '2dsphere' })

Si ya tienes datos en la coleccion cuando anadir el indice, MongoDB lo construira automaticamente sobre los documentos existentes.


3. Usar $near dentro de una aggregation

Problema: Intentar usar $near dentro de un pipeline de aggregation ($match).

// MAL - $near NO funciona en aggregation
Restaurant.aggregate([
  { $match: { location: { $near: { ... } } } }
])

Error que veras:

MongoServerError: $near is not allowed inside of $match aggregation operator

Solucion: Para busquedas de proximidad en aggregation, usa $geoNear como primer stage del pipeline:

// BIEN - usar $geoNear en aggregation
Restaurant.aggregate([
  {
    $geoNear: {
      near: { type: 'Point', coordinates: [lng, lat] },
      distanceField: 'distance',
      maxDistance: 1000,
      spherical: true,
    },
  },
])

// O simplemente usa find() con $near si no necesitas aggregation
Restaurant.find({ location: { $near: { ... } } })

4. Radio en metros en lugar de radianes para $centerSphere

Problema: Pasar metros directamente a $centerSphere sin convertir.

// MAL - metros directamente
$centerSphere: [[-3.7038, 40.4168], 2000]

// BIEN - convertir a radianes
$centerSphere: [[-3.7038, 40.4168], 2000 / 6378100]

Como detectarlo: Los resultados incluiran restaurantes que estan claramente fuera del radio esperado (o no devolvera ningun resultado porque el radio resultante es enorme).


Instalacion y uso

Requisitos previos

  • Node.js v18 o superior
  • MongoDB (local o Atlas)
  • Un cliente REST (curl, Postman, Insomnia...)

Pasos

1. Instalar dependencias

npm install

2. Configurar variables de entorno

Crea un archivo .env en la raiz del proyecto:

# .env
MONGODB_URI=mongodb://localhost:27017/geo-api
PORT=5005
CORS_ORIGIN=http://localhost:5173

Si usas MongoDB Atlas, sustituye MONGODB_URI por tu connection string de Atlas.

3. Poblar la base de datos con datos de prueba

npm run seed

Este comando crea 20 restaurantes distribuidos por el centro de Madrid con coordenadas reales. Puedes ejecutarlo varias veces — primero borra los datos existentes y luego los vuelve a crear.

4. Arrancar el servidor en modo desarrollo

npm run dev

El servidor arrancara en http://localhost:5005.


Endpoints de la API

Crear un restaurante

POST /api/restaurants
curl -X POST http://localhost:5005/api/restaurants \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Casa Lucio",
    "cuisine": "Spanish",
    "address": "Calle Cava Baja, 35, Madrid",
    "location": {
      "type": "Point",
      "coordinates": [-3.7089, 40.4133]
    }
  }'

Listar todos los restaurantes

GET /api/restaurants
# Sin filtros - devuelve todos
curl http://localhost:5005/api/restaurants

# Filtrar por cocina
curl "http://localhost:5005/api/restaurants?cuisine=Italian"

# $near - restaurantes a menos de 1km (ordenados por distancia)
curl "http://localhost:5005/api/restaurants?lat=40.4168&lng=-3.7038&maxDistance=1000"

# $geoWithin - restaurantes dentro de 2km (sin ordenar por distancia)
curl "http://localhost:5005/api/restaurants?lat=40.4168&lng=-3.7038&radius=2000"

# Combinar geo + cocina
curl "http://localhost:5005/api/restaurants?lat=40.4168&lng=-3.7038&radius=3000&cuisine=Japanese"

Obtener un restaurante por ID

GET /api/restaurants/:id
curl http://localhost:5005/api/restaurants/64a1b2c3d4e5f6789012345a

Actualizar un restaurante

PATCH /api/restaurants/:id
curl -X PATCH http://localhost:5005/api/restaurants/64a1b2c3d4e5f6789012345a \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Casa Lucio (Actualizado)",
    "cuisine": "Spanish Traditional"
  }'

Eliminar un restaurante

DELETE /api/restaurants/:id
curl -X DELETE http://localhost:5005/api/restaurants/64a1b2c3d4e5f6789012345a

Estructura del proyecto

geo-api/
├── app.js                          # Configuracion de Express
├── package.json
├── .env                            # Variables de entorno (no subir a Git)
├── .gitignore
├── README.md
├── bin/
│   └── seeds.js                    # Script para poblar la base de datos
├── config/
│   ├── db.config.js                # Conexion a MongoDB
│   └── routes.config.js            # Registro de rutas
├── controllers/
│   └── restaurants.controller.js   # Logica de negocio y consultas geo
├── middlewares/
│   ├── cors.middleware.js          # Configuracion de CORS
│   ├── clearbody.middleware.js     # Limpieza de campos vacios en req.body
│   └── errors.middleware.js        # Manejo centralizado de errores
└── models/
    └── restaurant.model.js         # Schema con campo GeoJSON + indice 2dsphere

Recursos adicionales


Happy coding!

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors