12. Chapter 12: Backend APIs and Data Management#
12.1. Learning Objectives#
By the end of this chapter, you will understand:
Designing RESTful APIs for geospatial data and services
Database design and optimization for geographic information
Implementing real-time data streams and WebSocket connections
Caching strategies and performance optimization for GIS backends
Integration with external GIS services and data sources
12.2. Backend Architecture for Web GIS#
Modern Web GIS applications require robust backend systems that can efficiently handle geospatial data storage, processing, and delivery. The backend serves as the foundation that enables frontend applications to access, manipulate, and visualize geographic information at scale.
12.2.1. Understanding GIS Backend Requirements#
Data Volume and Complexity: Geospatial applications often deal with massive datasets containing millions of features, complex geometries, and rich attribute data. Backend systems must be designed to handle this scale efficiently while maintaining fast query performance and data integrity.
Spatial Query Performance: Unlike traditional databases, GIS backends must excel at spatial queries like “find all points within a polygon,” “calculate distances between features,” or “identify intersections between layers.” These operations require specialized indexing and query optimization techniques.
Real-time Data Integration: Many modern GIS applications need to integrate real-time data streams from sensors, GPS devices, social media, or other dynamic sources. The backend must handle continuous data ingestion while maintaining system performance.
Multi-format Data Support: GIS backends must handle various data formats including GeoJSON, Shapefile, KML, GPX, and vector tiles. The system should provide format conversion and serve data in the most appropriate format for each client.
Scalability and Availability: As organizations grow and datasets expand, the backend must scale horizontally and maintain high availability. This requires careful architecture decisions around database clustering, caching, and load balancing.
12.2.2. Modern Backend Architecture Patterns#
Microservices Architecture: Breaking the backend into specialized microservices enables better scalability, maintainability, and technology diversity. Each service can be optimized for specific functions like data ingestion, spatial processing, or tile generation.
Event-Driven Architecture: Using event streams for communication between services enables real-time data processing and loosely coupled systems. This pattern is particularly valuable for handling streaming geospatial data.
API Gateway Pattern: A centralized API gateway provides authentication, rate limiting, request routing, and API versioning. This simplifies client integration and provides a single point for cross-cutting concerns.
CQRS (Command Query Responsibility Segregation): Separating read and write operations allows optimization of each for their specific use cases. Write operations can focus on data integrity while read operations optimize for query performance.
12.3. RESTful API Design for Geospatial Data#
12.3.1. Core API Design Principles#
Designing effective APIs for geospatial data requires understanding both RESTful principles and the unique characteristics of geographic information.
// Core API structure for a GIS application
interface GeoAPI {
// Feature collections
GET: '/api/v1/datasets/{datasetId}/features' // List features with spatial filtering
POST: '/api/v1/datasets/{datasetId}/features' // Create new features
PUT: '/api/v1/datasets/{datasetId}/features/{featureId}' // Update feature
DELETE: '/api/v1/datasets/{datasetId}/features/{featureId}' // Delete feature
// Spatial queries
GET: '/api/v1/spatial/within' // Features within geometry
GET: '/api/v1/spatial/intersects' // Features intersecting geometry
GET: '/api/v1/spatial/buffer' // Buffer operations
// Tile services
GET: '/api/v1/tiles/{z}/{x}/{y}.mvt' // Vector tiles
GET: '/api/v1/tiles/{z}/{x}/{y}.png' // Raster tiles
// Analytics
GET: '/api/v1/analytics/aggregate' // Spatial aggregations
POST: '/api/v1/analytics/calculate' // Complex spatial calculations
}
12.3.2. Express.js API Implementation#
// server/src/app.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import rateLimit from 'express-rate-limit';
import { errorHandler, notFound } from './middleware/errorHandler';
import { authMiddleware } from './middleware/auth';
import { validateRequest } from './middleware/validation';
const app = express();
// Security and optimization middleware
app.use(helmet());
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
credentials: true
}));
app.use(compression());
app.use(express.json({ limit: '10mb' }));
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 1000, // limit each IP to 1000 requests per windowMs
message: 'Too many requests from this IP'
});
app.use(limiter);
// Routes
import { featuresRouter } from './routes/features';
import { spatialRouter } from './routes/spatial';
import { tilesRouter } from './routes/tiles';
import { analyticsRouter } from './routes/analytics';
app.use('/api/v1/features', authMiddleware, featuresRouter);
app.use('/api/v1/spatial', authMiddleware, spatialRouter);
app.use('/api/v1/tiles', tilesRouter); // Public tiles
app.use('/api/v1/analytics', authMiddleware, analyticsRouter);
// Error handling
app.use(notFound);
app.use(errorHandler);
export default app;
// server/src/routes/features.ts
import { Router } from 'express';
import { body, query, param } from 'express-validator';
import { validateRequest } from '../middleware/validation';
import { FeaturesController } from '../controllers/featuresController';
const router = Router();
const featuresController = new FeaturesController();
// Get features with spatial and attribute filtering
router.get(
'/:datasetId',
[
param('datasetId').isUUID(),
query('bbox').optional().matches(/^-?\d+\.?\d*,-?\d+\.?\d*,-?\d+\.?\d*,-?\d+\.?\d*$/),
query('geometry').optional().isJSON(),
query('intersects').optional().isJSON(),
query('within').optional().isJSON(),
query('limit').optional().isInt({ min: 1, max: 10000 }).toInt(),
query('offset').optional().isInt({ min: 0 }).toInt(),
query('properties').optional().isJSON(),
query('orderBy').optional().isString(),
query('format').optional().isIn(['geojson', 'mvt', 'csv'])
],
validateRequest,
featuresController.getFeatures
);
// Create new feature
router.post(
'/:datasetId',
[
param('datasetId').isUUID(),
body('type').equals('Feature'),
body('geometry').isObject(),
body('properties').isObject()
],
validateRequest,
featuresController.createFeature
);
// Update feature
router.put(
'/:datasetId/:featureId',
[
param('datasetId').isUUID(),
param('featureId').isUUID(),
body('type').equals('Feature'),
body('geometry').isObject(),
body('properties').isObject()
],
validateRequest,
featuresController.updateFeature
);
// Delete feature
router.delete(
'/:datasetId/:featureId',
[
param('datasetId').isUUID(),
param('featureId').isUUID()
],
validateRequest,
featuresController.deleteFeature
);
// Bulk operations
router.post(
'/:datasetId/bulk',
[
param('datasetId').isUUID(),
body('type').equals('FeatureCollection'),
body('features').isArray()
],
validateRequest,
featuresController.bulkCreateFeatures
);
export { router as featuresRouter };
// server/src/controllers/featuresController.ts
import { Request, Response, NextFunction } from 'express';
import { FeaturesService } from '../services/featuresService';
import { CacheService } from '../services/cacheService';
import { ValidationError, NotFoundError } from '../utils/errors';
export class FeaturesController {
private featuresService = new FeaturesService();
private cacheService = new CacheService();
getFeatures = async (req: Request, res: Response, next: NextFunction) => {
try {
const { datasetId } = req.params;
const filters = this.parseFilters(req.query);
// Check cache first
const cacheKey = `features:${datasetId}:${JSON.stringify(filters)}`;
let cachedResult = await this.cacheService.get(cacheKey);
if (cachedResult) {
return res.json(cachedResult);
}
const result = await this.featuresService.getFeatures(datasetId, filters);
// Cache result for 5 minutes
await this.cacheService.set(cacheKey, result, 300);
res.json(result);
} catch (error) {
next(error);
}
};
createFeature = async (req: Request, res: Response, next: NextFunction) => {
try {
const { datasetId } = req.params;
const feature = req.body;
// Validate geometry
if (!this.isValidGeometry(feature.geometry)) {
throw new ValidationError('Invalid geometry');
}
const createdFeature = await this.featuresService.createFeature(datasetId, feature);
// Invalidate related caches
await this.cacheService.invalidatePattern(`features:${datasetId}:*`);
res.status(201).json(createdFeature);
} catch (error) {
next(error);
}
};
updateFeature = async (req: Request, res: Response, next: NextFunction) => {
try {
const { datasetId, featureId } = req.params;
const feature = req.body;
const updatedFeature = await this.featuresService.updateFeature(
datasetId,
featureId,
feature
);
if (!updatedFeature) {
throw new NotFoundError('Feature not found');
}
// Invalidate caches
await this.cacheService.invalidatePattern(`features:${datasetId}:*`);
res.json(updatedFeature);
} catch (error) {
next(error);
}
};
deleteFeature = async (req: Request, res: Response, next: NextFunction) => {
try {
const { datasetId, featureId } = req.params;
const deleted = await this.featuresService.deleteFeature(datasetId, featureId);
if (!deleted) {
throw new NotFoundError('Feature not found');
}
// Invalidate caches
await this.cacheService.invalidatePattern(`features:${datasetId}:*`);
res.status(204).send();
} catch (error) {
next(error);
}
};
bulkCreateFeatures = async (req: Request, res: Response, next: NextFunction) => {
try {
const { datasetId } = req.params;
const featureCollection = req.body;
const result = await this.featuresService.bulkCreateFeatures(
datasetId,
featureCollection
);
// Invalidate caches
await this.cacheService.invalidatePattern(`features:${datasetId}:*`);
res.status(201).json(result);
} catch (error) {
next(error);
}
};
private parseFilters(query: any) {
const filters: any = {};
if (query.bbox) {
const [minLng, minLat, maxLng, maxLat] = query.bbox.split(',').map(Number);
filters.bbox = { minLng, minLat, maxLng, maxLat };
}
if (query.geometry) {
filters.geometry = JSON.parse(query.geometry);
}
if (query.intersects) {
filters.intersects = JSON.parse(query.intersects);
}
if (query.within) {
filters.within = JSON.parse(query.within);
}
if (query.properties) {
filters.properties = JSON.parse(query.properties);
}
filters.limit = query.limit || 1000;
filters.offset = query.offset || 0;
filters.orderBy = query.orderBy;
filters.format = query.format || 'geojson';
return filters;
}
private isValidGeometry(geometry: any): boolean {
const validTypes = ['Point', 'LineString', 'Polygon', 'MultiPoint', 'MultiLineString', 'MultiPolygon'];
return geometry && validTypes.includes(geometry.type) && Array.isArray(geometry.coordinates);
}
}
12.3.3. Spatial Query Endpoints#
// server/src/routes/spatial.ts
import { Router } from 'express';
import { query } from 'express-validator';
import { validateRequest } from '../middleware/validation';
import { SpatialController } from '../controllers/spatialController';
const router = Router();
const spatialController = new SpatialController();
// Features within geometry
router.get(
'/within',
[
query('geometry').isJSON(),
query('datasets').isArray(),
query('limit').optional().isInt({ min: 1, max: 10000 })
],
validateRequest,
spatialController.getFeaturesWithin
);
// Features intersecting geometry
router.get(
'/intersects',
[
query('geometry').isJSON(),
query('datasets').isArray(),
query('buffer').optional().isFloat({ min: 0 })
],
validateRequest,
spatialController.getFeaturesIntersecting
);
// Nearest features
router.get(
'/nearest',
[
query('point').matches(/^-?\d+\.?\d*,-?\d+\.?\d*$/),
query('datasets').isArray(),
query('limit').optional().isInt({ min: 1, max: 100 }),
query('maxDistance').optional().isFloat({ min: 0 })
],
validateRequest,
spatialController.getNearestFeatures
);
// Buffer operation
router.post(
'/buffer',
[
query('geometry').isJSON(),
query('distance').isFloat(),
query('units').optional().isIn(['meters', 'kilometers', 'miles'])
],
validateRequest,
spatialController.createBuffer
);
export { router as spatialRouter };
// server/src/controllers/spatialController.ts
import { Request, Response, NextFunction } from 'express';
import { SpatialService } from '../services/spatialService';
export class SpatialController {
private spatialService = new SpatialService();
getFeaturesWithin = async (req: Request, res: Response, next: NextFunction) => {
try {
const geometry = JSON.parse(req.query.geometry as string);
const datasets = req.query.datasets as string[];
const limit = parseInt(req.query.limit as string) || 1000;
const features = await this.spatialService.getFeaturesWithin(
geometry,
datasets,
limit
);
res.json({
type: 'FeatureCollection',
features
});
} catch (error) {
next(error);
}
};
getFeaturesIntersecting = async (req: Request, res: Response, next: NextFunction) => {
try {
const geometry = JSON.parse(req.query.geometry as string);
const datasets = req.query.datasets as string[];
const buffer = parseFloat(req.query.buffer as string) || 0;
const features = await this.spatialService.getFeaturesIntersecting(
geometry,
datasets,
buffer
);
res.json({
type: 'FeatureCollection',
features
});
} catch (error) {
next(error);
}
};
getNearestFeatures = async (req: Request, res: Response, next: NextFunction) => {
try {
const [lng, lat] = (req.query.point as string).split(',').map(Number);
const point = { type: 'Point', coordinates: [lng, lat] };
const datasets = req.query.datasets as string[];
const limit = parseInt(req.query.limit as string) || 10;
const maxDistance = parseFloat(req.query.maxDistance as string);
const features = await this.spatialService.getNearestFeatures(
point,
datasets,
limit,
maxDistance
);
res.json({
type: 'FeatureCollection',
features
});
} catch (error) {
next(error);
}
};
createBuffer = async (req: Request, res: Response, next: NextFunction) => {
try {
const geometry = JSON.parse(req.query.geometry as string);
const distance = parseFloat(req.query.distance as string);
const units = req.query.units as string || 'meters';
const bufferedGeometry = await this.spatialService.createBuffer(
geometry,
distance,
units
);
res.json({
type: 'Feature',
geometry: bufferedGeometry,
properties: {
originalGeometry: geometry,
bufferDistance: distance,
units
}
});
} catch (error) {
next(error);
}
};
}
12.4. Database Design and Optimization#
12.4.1. PostgreSQL with PostGIS#
PostgreSQL with the PostGIS extension is the gold standard for geospatial database applications.
-- Database setup and extensions
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- For text search
-- Core schema design
CREATE SCHEMA IF NOT EXISTS gis;
-- Datasets table
CREATE TABLE gis.datasets (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_by UUID NOT NULL,
is_public BOOLEAN DEFAULT FALSE,
metadata JSONB DEFAULT '{}',
CONSTRAINT unique_dataset_name_per_user UNIQUE (name, created_by)
);
-- Create indexes for datasets
CREATE INDEX idx_datasets_created_by ON gis.datasets(created_by);
CREATE INDEX idx_datasets_public ON gis.datasets(is_public) WHERE is_public = TRUE;
CREATE INDEX idx_datasets_metadata ON gis.datasets USING GIN(metadata);
-- Features table with optimized structure
CREATE TABLE gis.features (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
dataset_id UUID NOT NULL REFERENCES gis.datasets(id) ON DELETE CASCADE,
geometry GEOMETRY(Geometry, 4326) NOT NULL,
properties JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
version INTEGER DEFAULT 1,
-- Denormalized columns for faster queries
centroid GEOMETRY(Point, 4326),
bbox GEOMETRY(Polygon, 4326),
area DOUBLE PRECISION,
length DOUBLE PRECISION
);
-- Create spatial indexes
CREATE INDEX idx_features_geometry ON gis.features USING GIST(geometry);
CREATE INDEX idx_features_centroid ON gis.features USING GIST(centroid);
CREATE INDEX idx_features_bbox ON gis.features USING GIST(bbox);
CREATE INDEX idx_features_dataset_id ON gis.features(dataset_id);
CREATE INDEX idx_features_properties ON gis.features USING GIN(properties);
-- Create partial indexes for common queries
CREATE INDEX idx_features_points ON gis.features USING GIST(geometry)
WHERE ST_GeometryType(geometry) = 'ST_Point';
CREATE INDEX idx_features_polygons ON gis.features USING GIST(geometry)
WHERE ST_GeometryType(geometry) IN ('ST_Polygon', 'ST_MultiPolygon');
CREATE INDEX idx_features_lines ON gis.features USING GIST(geometry)
WHERE ST_GeometryType(geometry) IN ('ST_LineString', 'ST_MultiLineString');
-- Trigger to update derived columns
CREATE OR REPLACE FUNCTION update_feature_derived_columns()
RETURNS TRIGGER AS $$
BEGIN
-- Update centroid
NEW.centroid = ST_Centroid(NEW.geometry);
-- Update bounding box
NEW.bbox = ST_Envelope(NEW.geometry);
-- Update area for polygons
IF ST_GeometryType(NEW.geometry) IN ('ST_Polygon', 'ST_MultiPolygon') THEN
NEW.area = ST_Area(ST_Transform(NEW.geometry, 3857)); -- Web Mercator for area calculation
END IF;
-- Update length for lines
IF ST_GeometryType(NEW.geometry) IN ('ST_LineString', 'ST_MultiLineString') THEN
NEW.length = ST_Length(ST_Transform(NEW.geometry, 3857));
END IF;
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_feature_derived_columns
BEFORE INSERT OR UPDATE OF geometry ON gis.features
FOR EACH ROW
EXECUTE FUNCTION update_feature_derived_columns();
-- Tile cache table for vector tiles
CREATE TABLE gis.tile_cache (
id SERIAL PRIMARY KEY,
dataset_id UUID NOT NULL REFERENCES gis.datasets(id) ON DELETE CASCADE,
z INTEGER NOT NULL,
x INTEGER NOT NULL,
y INTEGER NOT NULL,
tile_data BYTEA NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + INTERVAL '1 hour',
CONSTRAINT unique_tile UNIQUE (dataset_id, z, x, y)
);
CREATE INDEX idx_tile_cache_dataset_zxy ON gis.tile_cache(dataset_id, z, x, y);
CREATE INDEX idx_tile_cache_expires_at ON gis.tile_cache(expires_at);
-- Spatial query functions
CREATE OR REPLACE FUNCTION gis.get_features_within_bbox(
p_dataset_id UUID,
p_min_lng DOUBLE PRECISION,
p_min_lat DOUBLE PRECISION,
p_max_lng DOUBLE PRECISION,
p_max_lat DOUBLE PRECISION,
p_limit INTEGER DEFAULT 1000,
p_offset INTEGER DEFAULT 0
)
RETURNS TABLE (
id UUID,
geometry_geojson TEXT,
properties JSONB
) AS $$
BEGIN
RETURN QUERY
SELECT
f.id,
ST_AsGeoJSON(f.geometry)::TEXT as geometry_geojson,
f.properties
FROM gis.features f
WHERE f.dataset_id = p_dataset_id
AND ST_Intersects(
f.geometry,
ST_MakeEnvelope(p_min_lng, p_min_lat, p_max_lng, p_max_lat, 4326)
)
ORDER BY f.created_at DESC
LIMIT p_limit
OFFSET p_offset;
END;
$$ LANGUAGE plpgsql;
-- Spatial aggregation function
CREATE OR REPLACE FUNCTION gis.aggregate_features_in_grid(
p_dataset_id UUID,
p_grid_size DOUBLE PRECISION,
p_bounds GEOMETRY DEFAULT NULL
)
RETURNS TABLE (
grid_id TEXT,
cell_geometry TEXT,
feature_count INTEGER,
total_area DOUBLE PRECISION,
avg_area DOUBLE PRECISION
) AS $$
DECLARE
bounds_geom GEOMETRY;
BEGIN
-- Use provided bounds or calculate from dataset
IF p_bounds IS NULL THEN
SELECT ST_Extent(geometry) INTO bounds_geom
FROM gis.features
WHERE dataset_id = p_dataset_id;
ELSE
bounds_geom := p_bounds;
END IF;
RETURN QUERY
WITH grid_cells AS (
SELECT
(ST_SquareGrid(p_grid_size, bounds_geom)).*
),
aggregated AS (
SELECT
gc.i || '_' || gc.j as grid_id,
gc.geom as cell_geom,
COUNT(f.id) as feature_count,
COALESCE(SUM(f.area), 0) as total_area,
COALESCE(AVG(f.area), 0) as avg_area
FROM grid_cells gc
LEFT JOIN gis.features f ON (
f.dataset_id = p_dataset_id AND
ST_Intersects(f.geometry, gc.geom)
)
GROUP BY gc.i, gc.j, gc.geom
)
SELECT
a.grid_id,
ST_AsGeoJSON(a.cell_geom)::TEXT,
a.feature_count::INTEGER,
a.total_area,
a.avg_area
FROM aggregated a
WHERE a.feature_count > 0;
END;
$$ LANGUAGE plpgsql;
12.4.2. Database Service Implementation#
// server/src/services/databaseService.ts
import { Pool, PoolClient } from 'pg';
import { DatabaseError } from '../utils/errors';
export class DatabaseService {
private pool: Pool;
constructor() {
this.pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
// Handle pool events
this.pool.on('error', (err) => {
console.error('Unexpected error on idle client', err);
});
}
async query(text: string, params?: any[]): Promise<any> {
const start = Date.now();
try {
const result = await this.pool.query(text, params);
const duration = Date.now() - start;
// Log slow queries
if (duration > 1000) {
console.warn(`Slow query (${duration}ms):`, text.substring(0, 100));
}
return result;
} catch (error) {
console.error('Database query error:', error);
throw new DatabaseError('Database query failed');
}
}
async transaction<T>(callback: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async close(): Promise<void> {
await this.pool.end();
}
}
// server/src/services/featuresService.ts
import { DatabaseService } from './databaseService';
import { CacheService } from './cacheService';
interface FeatureFilters {
bbox?: { minLng: number; minLat: number; maxLng: number; maxLat: number };
geometry?: GeoJSON.Geometry;
intersects?: GeoJSON.Geometry;
within?: GeoJSON.Geometry;
properties?: Record<string, any>;
limit?: number;
offset?: number;
orderBy?: string;
format?: string;
}
export class FeaturesService {
private db = new DatabaseService();
private cache = new CacheService();
async getFeatures(datasetId: string, filters: FeatureFilters): Promise<GeoJSON.FeatureCollection> {
try {
let query = `
SELECT
id,
ST_AsGeoJSON(geometry) as geometry,
properties,
created_at
FROM gis.features f
WHERE f.dataset_id = $1
`;
const params: any[] = [datasetId];
let paramIndex = 2;
// Add spatial filters
if (filters.bbox) {
query += ` AND ST_Intersects(f.geometry, ST_MakeEnvelope($${paramIndex}, $${paramIndex + 1}, $${paramIndex + 2}, $${paramIndex + 3}, 4326))`;
params.push(filters.bbox.minLng, filters.bbox.minLat, filters.bbox.maxLng, filters.bbox.maxLat);
paramIndex += 4;
}
if (filters.intersects) {
query += ` AND ST_Intersects(f.geometry, ST_GeomFromGeoJSON($${paramIndex}))`;
params.push(JSON.stringify(filters.intersects));
paramIndex++;
}
if (filters.within) {
query += ` AND ST_Within(f.geometry, ST_GeomFromGeoJSON($${paramIndex}))`;
params.push(JSON.stringify(filters.within));
paramIndex++;
}
// Add property filters
if (filters.properties) {
for (const [key, value] of Object.entries(filters.properties)) {
query += ` AND f.properties ->> $${paramIndex} = $${paramIndex + 1}`;
params.push(key, value);
paramIndex += 2;
}
}
// Add ordering
if (filters.orderBy) {
const orderByClause = this.buildOrderByClause(filters.orderBy);
query += ` ORDER BY ${orderByClause}`;
} else {
query += ` ORDER BY f.created_at DESC`;
}
// Add pagination
query += ` LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
params.push(filters.limit || 1000, filters.offset || 0);
const result = await this.db.query(query, params);
const features: GeoJSON.Feature[] = result.rows.map(row => ({
type: 'Feature',
id: row.id,
geometry: JSON.parse(row.geometry),
properties: row.properties
}));
return {
type: 'FeatureCollection',
features
};
} catch (error) {
console.error('Error getting features:', error);
throw error;
}
}
async createFeature(datasetId: string, feature: GeoJSON.Feature): Promise<GeoJSON.Feature> {
try {
const query = `
INSERT INTO gis.features (dataset_id, geometry, properties)
VALUES ($1, ST_GeomFromGeoJSON($2), $3)
RETURNING id, ST_AsGeoJSON(geometry) as geometry, properties, created_at
`;
const params = [
datasetId,
JSON.stringify(feature.geometry),
feature.properties || {}
];
const result = await this.db.query(query, params);
const row = result.rows[0];
return {
type: 'Feature',
id: row.id,
geometry: JSON.parse(row.geometry),
properties: row.properties
};
} catch (error) {
console.error('Error creating feature:', error);
throw error;
}
}
async updateFeature(
datasetId: string,
featureId: string,
feature: GeoJSON.Feature
): Promise<GeoJSON.Feature | null> {
try {
const query = `
UPDATE gis.features
SET geometry = ST_GeomFromGeoJSON($1), properties = $2, version = version + 1
WHERE id = $3 AND dataset_id = $4
RETURNING id, ST_AsGeoJSON(geometry) as geometry, properties, updated_at
`;
const params = [
JSON.stringify(feature.geometry),
feature.properties || {},
featureId,
datasetId
];
const result = await this.db.query(query, params);
if (result.rows.length === 0) {
return null;
}
const row = result.rows[0];
return {
type: 'Feature',
id: row.id,
geometry: JSON.parse(row.geometry),
properties: row.properties
};
} catch (error) {
console.error('Error updating feature:', error);
throw error;
}
}
async deleteFeature(datasetId: string, featureId: string): Promise<boolean> {
try {
const query = `
DELETE FROM gis.features
WHERE id = $1 AND dataset_id = $2
`;
const result = await this.db.query(query, [featureId, datasetId]);
return result.rowCount > 0;
} catch (error) {
console.error('Error deleting feature:', error);
throw error;
}
}
async bulkCreateFeatures(
datasetId: string,
featureCollection: GeoJSON.FeatureCollection
): Promise<{ created: number; errors: any[] }> {
const created: number[] = [];
const errors: any[] = [];
await this.db.transaction(async (client) => {
for (let i = 0; i < featureCollection.features.length; i++) {
const feature = featureCollection.features[i];
try {
const query = `
INSERT INTO gis.features (dataset_id, geometry, properties)
VALUES ($1, ST_GeomFromGeoJSON($2), $3)
RETURNING id
`;
const params = [
datasetId,
JSON.stringify(feature.geometry),
feature.properties || {}
];
const result = await client.query(query, params);
created.push(result.rows[0].id);
} catch (error) {
errors.push({
index: i,
feature,
error: error.message
});
}
}
});
return {
created: created.length,
errors
};
}
private buildOrderByClause(orderBy: string): string {
const allowedColumns = ['created_at', 'updated_at', 'area', 'length'];
const [column, direction = 'ASC'] = orderBy.split(':');
if (!allowedColumns.includes(column)) {
return 'created_at DESC';
}
const dir = direction.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
return `${column} ${dir}`;
}
}
12.5. Real-time Data Streams#
12.5.1. WebSocket Implementation#
// server/src/services/websocketService.ts
import { Server } from 'socket.io';
import { Server as HttpServer } from 'http';
import jwt from 'jsonwebtoken';
import { RedisAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
interface AuthenticatedSocket extends Socket {
userId: string;
datasets: string[];
}
export class WebSocketService {
private io: Server;
private connectedUsers = new Map<string, AuthenticatedSocket>();
constructor(httpServer: HttpServer) {
this.io = new Server(httpServer, {
cors: {
origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
credentials: true
},
transports: ['websocket', 'polling']
});
// Setup Redis adapter for horizontal scaling
this.setupRedisAdapter();
// Setup authentication middleware
this.setupAuthMiddleware();
// Setup connection handling
this.setupConnectionHandling();
}
private async setupRedisAdapter() {
if (process.env.REDIS_URL) {
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
this.io.adapter(new RedisAdapter(pubClient, subClient));
}
}
private setupAuthMiddleware() {
this.io.use(async (socket, next) => {
try {
const token = socket.handshake.auth.token;
if (!token) {
throw new Error('No token provided');
}
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as any;
// Attach user info to socket
(socket as AuthenticatedSocket).userId = decoded.userId;
(socket as AuthenticatedSocket).datasets = decoded.datasets || [];
next();
} catch (error) {
next(new Error('Authentication failed'));
}
});
}
private setupConnectionHandling() {
this.io.on('connection', (socket: AuthenticatedSocket) => {
console.log(`User ${socket.userId} connected`);
this.connectedUsers.set(socket.userId, socket);
// Join user to their dataset rooms
socket.datasets.forEach(datasetId => {
socket.join(`dataset:${datasetId}`);
});
// Handle subscription to specific datasets
socket.on('subscribe:dataset', (datasetId: string) => {
if (socket.datasets.includes(datasetId)) {
socket.join(`dataset:${datasetId}`);
socket.emit('subscribed', { dataset: datasetId });
} else {
socket.emit('error', { message: 'Unauthorized access to dataset' });
}
});
// Handle unsubscription
socket.on('unsubscribe:dataset', (datasetId: string) => {
socket.leave(`dataset:${datasetId}`);
socket.emit('unsubscribed', { dataset: datasetId });
});
// Handle real-time feature updates
socket.on('feature:update', async (data) => {
try {
await this.handleFeatureUpdate(socket, data);
} catch (error) {
socket.emit('error', { message: 'Failed to update feature' });
}
});
// Handle real-time position updates
socket.on('position:update', (data) => {
this.handlePositionUpdate(socket, data);
});
// Handle disconnection
socket.on('disconnect', () => {
console.log(`User ${socket.userId} disconnected`);
this.connectedUsers.delete(socket.userId);
});
});
}
private async handleFeatureUpdate(socket: AuthenticatedSocket, data: any) {
const { datasetId, featureId, feature, operation } = data;
// Verify user has access to dataset
if (!socket.datasets.includes(datasetId)) {
throw new Error('Unauthorized');
}
// Broadcast update to all users subscribed to this dataset
socket.to(`dataset:${datasetId}`).emit('feature:updated', {
datasetId,
featureId,
feature,
operation,
userId: socket.userId,
timestamp: new Date().toISOString()
});
// Acknowledge the update
socket.emit('feature:update:ack', { featureId, operation });
}
private handlePositionUpdate(socket: AuthenticatedSocket, data: any) {
const { datasetId, position } = data;
if (!socket.datasets.includes(datasetId)) {
return;
}
// Broadcast position to other users
socket.to(`dataset:${datasetId}`).emit('user:position', {
userId: socket.userId,
position,
timestamp: new Date().toISOString()
});
}
// Public methods for broadcasting updates
public broadcastFeatureUpdate(datasetId: string, update: any) {
this.io.to(`dataset:${datasetId}`).emit('feature:updated', update);
}
public broadcastDatasetUpdate(datasetId: string, update: any) {
this.io.to(`dataset:${datasetId}`).emit('dataset:updated', update);
}
public notifyUser(userId: string, event: string, data: any) {
const socket = this.connectedUsers.get(userId);
if (socket) {
socket.emit(event, data);
}
}
public getConnectedUsers(): string[] {
return Array.from(this.connectedUsers.keys());
}
public getUsersInDataset(datasetId: string): string[] {
const users: string[] = [];
this.connectedUsers.forEach((socket, userId) => {
if (socket.datasets.includes(datasetId)) {
users.push(userId);
}
});
return users;
}
}
// server/src/services/streamingService.ts
import { EventEmitter } from 'events';
import { WebSocketService } from './websocketService';
import { QueueService } from './queueService';
interface StreamingDataPoint {
id: string;
datasetId: string;
timestamp: Date;
position?: GeoJSON.Point;
properties: Record<string, any>;
}
export class StreamingService extends EventEmitter {
private websocketService: WebSocketService;
private queueService: QueueService;
private processingIntervals = new Map<string, NodeJS.Timeout>();
constructor(websocketService: WebSocketService) {
super();
this.websocketService = websocketService;
this.queueService = new QueueService();
this.setupDataProcessing();
}
private setupDataProcessing() {
// Process incoming data streams
this.queueService.subscribe('streaming:data', async (data: StreamingDataPoint) => {
await this.processStreamingData(data);
});
// Process batch updates
this.queueService.subscribe('streaming:batch', async (batch: StreamingDataPoint[]) => {
await this.processBatchData(batch);
});
}
private async processStreamingData(data: StreamingDataPoint) {
try {
// Validate and transform data
const transformedData = await this.transformData(data);
// Store in database
await this.storeStreamingData(transformedData);
// Broadcast to connected clients
this.websocketService.broadcastFeatureUpdate(data.datasetId, {
type: 'streaming:update',
data: transformedData,
timestamp: data.timestamp
});
// Emit event for other services
this.emit('data:received', transformedData);
} catch (error) {
console.error('Error processing streaming data:', error);
this.emit('data:error', { data, error });
}
}
private async processBatchData(batch: StreamingDataPoint[]) {
try {
const datasetGroups = new Map<string, StreamingDataPoint[]>();
// Group by dataset
batch.forEach(data => {
if (!datasetGroups.has(data.datasetId)) {
datasetGroups.set(data.datasetId, []);
}
datasetGroups.get(data.datasetId)!.push(data);
});
// Process each dataset group
for (const [datasetId, dataPoints] of datasetGroups) {
await this.processBatchForDataset(datasetId, dataPoints);
}
} catch (error) {
console.error('Error processing batch data:', error);
}
}
private async processBatchForDataset(datasetId: string, dataPoints: StreamingDataPoint[]) {
const transformedData = await Promise.all(
dataPoints.map(data => this.transformData(data))
);
// Store batch in database
await this.storeBatchData(datasetId, transformedData);
// Broadcast batch update
this.websocketService.broadcastDatasetUpdate(datasetId, {
type: 'batch:update',
data: transformedData,
count: transformedData.length,
timestamp: new Date().toISOString()
});
}
private async transformData(data: StreamingDataPoint): Promise<any> {
// Apply data transformations, validation, and enrichment
return {
...data,
processed: true,
processedAt: new Date().toISOString()
};
}
private async storeStreamingData(data: any): Promise<void> {
// Store individual data point
// This would typically use a time-series database or append-only storage
}
private async storeBatchData(datasetId: string, data: any[]): Promise<void> {
// Store batch data efficiently
// Consider using bulk insert operations
}
// Public API for starting/stopping data streams
public startDataStream(datasetId: string, interval: number = 1000) {
if (this.processingIntervals.has(datasetId)) {
this.stopDataStream(datasetId);
}
const intervalId = setInterval(() => {
this.processDataStreamTick(datasetId);
}, interval);
this.processingIntervals.set(datasetId, intervalId);
}
public stopDataStream(datasetId: string) {
const intervalId = this.processingIntervals.get(datasetId);
if (intervalId) {
clearInterval(intervalId);
this.processingIntervals.delete(datasetId);
}
}
private async processDataStreamTick(datasetId: string) {
// Process pending data for this dataset
// This could pull from a queue, external API, or generate synthetic data
}
public ingestData(data: StreamingDataPoint | StreamingDataPoint[]) {
if (Array.isArray(data)) {
this.queueService.publish('streaming:batch', data);
} else {
this.queueService.publish('streaming:data', data);
}
}
}
12.6. Caching and Performance Optimization#
12.6.1. Redis Caching Implementation#
// server/src/services/cacheService.ts
import Redis from 'ioredis';
import { promisify } from 'util';
import { gzip, gunzip } from 'zlib';
const gzipAsync = promisify(gzip);
const gunzipAsync = promisify(gunzip);
export class CacheService {
private redis: Redis;
private defaultTTL = 3600; // 1 hour
constructor() {
this.redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
password: process.env.REDIS_PASSWORD,
db: parseInt(process.env.REDIS_DB || '0'),
retryDelayOnFailover: 100,
maxRetriesPerRequest: 3,
lazyConnect: true
});
this.redis.on('error', (error) => {
console.error('Redis connection error:', error);
});
this.redis.on('connect', () => {
console.log('Connected to Redis');
});
}
async get<T>(key: string): Promise<T | null> {
try {
const cached = await this.redis.getBuffer(key);
if (!cached) {
return null;
}
// Decompress if compressed
const decompressed = await gunzipAsync(cached);
return JSON.parse(decompressed.toString());
} catch (error) {
console.error(`Cache get error for key ${key}:`, error);
return null;
}
}
async set(key: string, value: any, ttl: number = this.defaultTTL): Promise<void> {
try {
const serialized = JSON.stringify(value);
// Compress large data
const compressed = await gzipAsync(Buffer.from(serialized));
await this.redis.setex(key, ttl, compressed);
} catch (error) {
console.error(`Cache set error for key ${key}:`, error);
}
}
async del(key: string): Promise<void> {
try {
await this.redis.del(key);
} catch (error) {
console.error(`Cache delete error for key ${key}:`, error);
}
}
async invalidatePattern(pattern: string): Promise<void> {
try {
const keys = await this.redis.keys(pattern);
if (keys.length > 0) {
await this.redis.del(...keys);
}
} catch (error) {
console.error(`Cache pattern invalidation error for ${pattern}:`, error);
}
}
async getOrSet<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = this.defaultTTL
): Promise<T> {
let cached = await this.get<T>(key);
if (cached !== null) {
return cached;
}
const value = await fetcher();
await this.set(key, value, ttl);
return value;
}
// Spatial cache helpers
async cacheTile(datasetId: string, z: number, x: number, y: number, data: Buffer, ttl: number = 3600): Promise<void> {
const key = `tile:${datasetId}:${z}:${x}:${y}`;
await this.redis.setex(key, ttl, data);
}
async getTile(datasetId: string, z: number, x: number, y: number): Promise<Buffer | null> {
const key = `tile:${datasetId}:${z}:${x}:${y}`;
return this.redis.getBuffer(key);
}
async cacheGeospatialQuery(queryHash: string, result: any, ttl: number = 300): Promise<void> {
const key = `geo_query:${queryHash}`;
await this.set(key, result, ttl);
}
async getGeospatialQuery(queryHash: string): Promise<any> {
const key = `geo_query:${queryHash}`;
return this.get(key);
}
// Statistics and monitoring
async getStats(): Promise<any> {
try {
const info = await this.redis.info('memory');
const keyCount = await this.redis.dbsize();
return {
memory: this.parseRedisInfo(info),
keyCount,
connected: this.redis.status === 'ready'
};
} catch (error) {
return { error: error.message };
}
}
private parseRedisInfo(info: string): any {
const lines = info.split('\r\n');
const result: any = {};
lines.forEach(line => {
if (line.includes(':')) {
const [key, value] = line.split(':');
result[key] = value;
}
});
return result;
}
async close(): Promise<void> {
await this.redis.quit();
}
}
// server/src/middleware/caching.ts
import { Request, Response, NextFunction } from 'express';
import { CacheService } from '../services/cacheService';
import crypto from 'crypto';
interface CachingOptions {
ttl?: number;
keyGenerator?: (req: Request) => string;
condition?: (req: Request) => boolean;
}
export const createCachingMiddleware = (options: CachingOptions = {}) => {
const cacheService = new CacheService();
const defaultTTL = options.ttl || 300; // 5 minutes
return async (req: Request, res: Response, next: NextFunction) => {
// Skip caching for non-GET requests
if (req.method !== 'GET') {
return next();
}
// Check condition if provided
if (options.condition && !options.condition(req)) {
return next();
}
// Generate cache key
const cacheKey = options.keyGenerator
? options.keyGenerator(req)
: generateCacheKey(req);
try {
// Try to get from cache
const cached = await cacheService.get(cacheKey);
if (cached) {
res.set('X-Cache', 'HIT');
return res.json(cached);
}
// Store original json method
const originalJson = res.json;
// Override json method to cache response
res.json = function(data: any) {
// Cache the response
cacheService.set(cacheKey, data, defaultTTL).catch(error => {
console.error('Failed to cache response:', error);
});
res.set('X-Cache', 'MISS');
return originalJson.call(this, data);
};
next();
} catch (error) {
console.error('Cache middleware error:', error);
next();
}
};
};
const generateCacheKey = (req: Request): string => {
const parts = [
req.method,
req.originalUrl,
req.get('Accept') || '',
req.get('User-Agent') || ''
];
const combined = parts.join('|');
return `api:${crypto.createHash('md5').update(combined).digest('hex')}`;
};
// Usage example
import { featuresRouter } from './routes/features';
// Apply caching to feature endpoints
featuresRouter.use(createCachingMiddleware({
ttl: 600, // 10 minutes
condition: (req) => {
// Only cache if no real-time updates needed
return !req.query.realtime;
},
keyGenerator: (req) => {
// Custom key for geospatial queries
const { datasetId } = req.params;
const queryString = Object.keys(req.query)
.sort()
.map(key => `${key}=${req.query[key]}`)
.join('&');
return `features:${datasetId}:${crypto.createHash('md5').update(queryString).digest('hex')}`;
}
}));
12.7. Summary#
Building robust backend systems for Web GIS applications requires careful consideration of data architecture, API design, and performance optimization. The backend serves as the critical foundation that enables frontend applications to efficiently access, manipulate, and visualize geospatial data at scale.
Key architectural considerations include designing RESTful APIs that handle spatial queries efficiently, implementing database schemas optimized for geospatial operations, building real-time data streaming capabilities, and creating comprehensive caching strategies. PostgreSQL with PostGIS provides excellent spatial database capabilities, while Redis enables high-performance caching and session management.
Real-time features through WebSocket connections enable collaborative editing, live data updates, and dynamic visualizations. Proper error handling, authentication, and rate limiting ensure the system remains secure and performant under load.
The patterns and implementations covered in this chapter provide a solid foundation for building production-ready Web GIS backends that can scale with growing data volumes and user demands. The next chapter will explore authentication and user management, completing the full-stack application architecture.
12.8. Exercises#
12.8.1. Exercise 12.1: RESTful API Development#
Objective: Build a comprehensive RESTful API for geospatial data management with proper validation and error handling.
Instructions:
Design and implement core endpoints:
CRUD operations for datasets and features
Spatial query endpoints (within, intersects, nearest)
Bulk data operations with transaction support
File upload and processing endpoints
Add comprehensive validation:
Request validation using express-validator
GeoJSON geometry validation
File format validation and sanitization
Rate limiting and request size limits
Implement error handling and logging:
Custom error classes for different scenarios
Structured logging with request tracing
Error monitoring and alerting integration
Graceful error responses with helpful messages
Deliverable: A fully-featured RESTful API with comprehensive documentation and test coverage.
12.8.2. Exercise 12.2: Database Optimization#
Objective: Design and optimize a PostgreSQL database schema for high-performance geospatial operations.
Instructions:
Create optimized schema design:
Design tables for different geometry types
Implement proper indexing strategies
Add materialized views for common queries
Create stored functions for complex operations
Implement performance optimizations:
Spatial indexing with GIST indexes
Query optimization and explain plan analysis
Connection pooling and prepared statements
Database monitoring and metrics collection
Add data management features:
Data versioning and audit trails
Automated data archiving and cleanup
Database backup and recovery procedures
Performance testing with large datasets
Deliverable: An optimized database schema with performance benchmarks and management procedures.
12.8.3. Exercise 12.3: Real-time Data Streaming#
Objective: Implement real-time data streaming capabilities with WebSocket connections and message queuing.
Instructions:
Build WebSocket server:
Authentication and authorization for connections
Room-based subscriptions for datasets
Real-time data broadcasting
Connection monitoring and reconnection handling
Implement data streaming pipeline:
Message queue integration (Redis/RabbitMQ)
Data transformation and validation
Batch processing for high-volume streams
Error handling and retry mechanisms
Add collaborative features:
Real-time collaborative editing
User presence indicators
Conflict resolution for simultaneous edits
Real-time notifications and alerts
Deliverable: A complete real-time streaming system with collaborative editing capabilities.
12.8.4. Exercise 12.4: Caching Strategy Implementation#
Objective: Implement comprehensive caching strategies for improved performance and scalability.
Instructions:
Multi-level caching system:
In-memory caching for frequently accessed data
Redis caching for shared data across instances
CDN integration for static assets
Database query result caching
Spatial data caching:
Vector tile caching with proper invalidation
Spatial query result caching
Geometry processing result caching
Smart cache warming strategies
Cache management and monitoring:
Cache hit/miss ratio monitoring
Automated cache invalidation triggers
Cache size and memory usage monitoring
Performance impact measurement
Deliverable: A sophisticated caching system with monitoring and analytics.
12.8.5. Exercise 12.5: API Gateway and Microservices#
Objective: Implement an API gateway and microservices architecture for scalable Web GIS applications.
Instructions:
Design microservices architecture:
Separate services for different functions (data, tiles, analytics)
Service discovery and communication patterns
Load balancing and health checks
Inter-service authentication and authorization
Implement API gateway:
Request routing and load balancing
Authentication and authorization
Rate limiting and throttling
Request/response transformation
Add monitoring and observability:
Distributed tracing across services
Centralized logging and monitoring
Service mesh integration
Performance metrics and alerting
Deliverable: A microservices-based architecture with proper gateway and monitoring.
12.8.6. Exercise 12.6: External Service Integration#
Objective: Integrate with external GIS services and data sources for enhanced functionality.
Instructions:
Implement external data integration:
Integration with WMS/WFS services
Connection to external APIs (weather, traffic, etc.)
Data synchronization and update mechanisms
Format conversion and data transformation
Add geocoding and routing services:
Integration with geocoding APIs
Routing service integration
Batch processing for large datasets
Fallback strategies for service failures
Build data pipeline management:
Scheduled data imports and exports
Data quality validation and monitoring
Error handling and retry mechanisms
Data lineage and audit trails
Deliverable: A comprehensive integration system with external GIS services and data sources.
12.8.7. Exercise 12.7: Production Deployment#
Objective: Prepare and deploy a production-ready Web GIS backend with proper monitoring and scaling.
Instructions:
Production configuration:
Environment-specific configuration management
Security hardening and vulnerability assessment
SSL/TLS configuration and certificate management
Database security and backup procedures
Deployment automation:
Container orchestration with Docker/Kubernetes
CI/CD pipeline implementation
Automated testing and deployment
Blue-green deployment strategies
Monitoring and alerting:
Application performance monitoring
Infrastructure monitoring and alerting
Log aggregation and analysis
Disaster recovery procedures
Deliverable: A production-ready deployment with comprehensive monitoring and automated operations.
Reflection Questions:
How do spatial queries differ from traditional database queries in terms of performance optimization?
What are the key considerations when designing APIs for real-time geospatial applications?
How can caching strategies be adapted for the unique characteristics of geospatial data?
What are the trade-offs between microservices and monolithic architectures for Web GIS applications?