Node.js Integration
This guide shows how to integrate Firebase ORM with Node.js backend applications, covering Express.js APIs, serverless functions, and microservices architectures.
Quick Setup
1. Installation
npm install @arbel/firebase-orm firebase-admin moment --save
For development:
npm install --save-dev @types/node nodemon ts-node typescript
2. TypeScript Configuration
Create or update tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strictPropertyInitialization": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
3. Project Structure
src/
├── config/
│ ├── firebase.ts
│ └── database.ts
├── models/
│ ├── User.ts
│ ├── Post.ts
│ └── index.ts
├── controllers/
│ ├── userController.ts
│ └── postController.ts
├── routes/
│ ├── userRoutes.ts
│ └── postRoutes.ts
├── middleware/
│ ├── auth.ts
│ ├── validation.ts
│ └── errorHandler.ts
├── services/
│ ├── userService.ts
│ └── emailService.ts
├── utils/
│ ├── logger.ts
│ └── helpers.ts
├── app.ts
└── server.ts
Firebase Admin Setup
Configuration
// src/config/firebase.ts
import admin from 'firebase-admin';
import { FirestoreOrmRepository } from '@arbel/firebase-orm';
// Initialize Firebase Admin SDK
if (!admin.apps.length) {
// Method 1: Using service account file
if (process.env.GOOGLE_APPLICATION_CREDENTIALS) {
admin.initializeApp({
credential: admin.credential.applicationDefault(),
projectId: process.env.FIREBASE_PROJECT_ID,
});
}
// Method 2: Using service account object
else if (process.env.FIREBASE_PRIVATE_KEY) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
}),
});
}
// Method 3: Using default credentials (Google Cloud)
else {
admin.initializeApp();
}
}
const firestore = admin.firestore();
// Initialize Firebase ORM
FirestoreOrmRepository.initGlobalConnection(firestore);
export { admin, firestore };
Environment Variables
# .env
NODE_ENV=development
PORT=3000
# Firebase Configuration
FIREBASE_PROJECT_ID=your-project-id
FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
# Alternative: Path to service account file
# GOOGLE_APPLICATION_CREDENTIALS=./serviceAccountKey.json
# Security
JWT_SECRET=your-jwt-secret
API_KEY=your-api-key
# External Services
SENDGRID_API_KEY=your-sendgrid-key
Models
User Model
// src/models/User.ts
import { BaseModel, Model, Field, HasMany, BelongsToMany } from '@arbel/firebase-orm';
import { Post } from './Post';
import { Role } from './Role';
@Model({
reference_path: 'users',
path_id: 'user_id'
})
export class User extends BaseModel {
@Field({ is_required: true })
public name!: string;
@Field({ is_required: true })
public email!: string;
@Field({ field_name: 'password_hash' })
public passwordHash?: string;
@Field({ field_name: 'created_at' })
public createdAt?: string;
@Field({ field_name: 'updated_at' })
public updatedAt?: string;
@Field({ is_required: false })
public bio?: string;
@Field({ is_required: false })
public avatar?: string;
@Field({ is_required: false, default_value: true })
public isActive?: boolean;
@Field({ is_required: false, field_name: 'last_login' })
public lastLogin?: string;
@HasMany({ model: Post, foreignKey: 'author_id' })
public posts?: Post[];
@BelongsToMany({
model: Role,
junctionModel: 'UserRole',
localKey: 'user_id',
foreignKey: 'role_id'
})
public roles?: Role[];
// Helper methods
toJSON() {
return {
id: this.getId(),
name: this.name,
email: this.email,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
bio: this.bio,
avatar: this.avatar,
isActive: this.isActive,
lastLogin: this.lastLogin,
};
}
toPublicJSON() {
return {
id: this.getId(),
name: this.name,
bio: this.bio,
avatar: this.avatar,
createdAt: this.createdAt,
};
}
static async findByEmail(email: string): Promise<User | null> {
const users = await User.query().where('email', '==', email).get();
return users.length > 0 ? users[0] : null;
}
static async findActive(): Promise<User[]> {
return await User.query().where('isActive', '==', true).get();
}
async updateLastLogin(): Promise<void> {
this.lastLogin = new Date().toISOString();
this.updatedAt = new Date().toISOString();
await this.save();
}
async softDelete(): Promise<void> {
this.isActive = false;
this.updatedAt = new Date().toISOString();
await this.save();
}
async hasRole(roleName: string): Promise<boolean> {
if (!this.roles) {
await this.loadBelongsToMany('roles');
}
return this.roles?.some(role => role.name === roleName) ?? false;
}
}
Post Model
// src/models/Post.ts
import { BaseModel, Model, Field, BelongsTo } from '@arbel/firebase-orm';
import { User } from './User';
export enum PostStatus {
DRAFT = 'draft',
PUBLISHED = 'published',
ARCHIVED = 'archived'
}
@Model({
reference_path: 'posts',
path_id: 'post_id'
})
export class Post extends BaseModel {
@Field({ is_required: true })
public title!: string;
@Field({ is_required: true })
public content!: string;
@Field({ is_required: false })
public excerpt?: string;
@Field({ field_name: 'author_id' })
public authorId!: string;
@Field({ is_required: false, default_value: PostStatus.DRAFT })
public status?: PostStatus;
@Field({ field_name: 'created_at' })
public createdAt?: string;
@Field({ field_name: 'updated_at' })
public updatedAt?: string;
@Field({ field_name: 'published_at' })
public publishedAt?: string;
@Field({ is_required: false })
public tags?: string[];
@Field({ is_required: false, field_name: 'view_count', default_value: 0 })
public viewCount?: number;
@BelongsTo({ model: User, localKey: 'authorId' })
public author?: User;
// Helper methods
toJSON() {
return {
id: this.getId(),
title: this.title,
content: this.content,
excerpt: this.excerpt,
authorId: this.authorId,
status: this.status,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
publishedAt: this.publishedAt,
tags: this.tags || [],
viewCount: this.viewCount || 0,
};
}
toPublicJSON() {
return {
id: this.getId(),
title: this.title,
excerpt: this.excerpt || this.content.substring(0, 150) + '...',
authorId: this.authorId,
publishedAt: this.publishedAt,
tags: this.tags || [],
viewCount: this.viewCount || 0,
};
}
static async findPublished(): Promise<Post[]> {
return await Post.query()
.where('status', '==', PostStatus.PUBLISHED)
.orderBy('publishedAt', 'desc')
.get();
}
static async findByAuthor(authorId: string): Promise<Post[]> {
return await Post.query()
.where('authorId', '==', authorId)
.orderBy('createdAt', 'desc')
.get();
}
static async findByTag(tag: string): Promise<Post[]> {
return await Post.query()
.where('tags', 'array-contains', tag)
.where('status', '==', PostStatus.PUBLISHED)
.get();
}
async publish(): Promise<void> {
this.status = PostStatus.PUBLISHED;
this.publishedAt = new Date().toISOString();
this.updatedAt = new Date().toISOString();
await this.save();
}
async incrementViewCount(): Promise<void> {
this.viewCount = (this.viewCount || 0) + 1;
await this.save();
}
generateExcerpt(length: number = 150): string {
if (this.excerpt) return this.excerpt;
return this.content.length > length
? this.content.substring(0, length) + '...'
: this.content;
}
}
Services
User Service
// src/services/userService.ts
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { User } from '../models/User';
import { emailService } from './emailService';
export interface CreateUserData {
name: string;
email: string;
password: string;
bio?: string;
avatar?: string;
}
export interface UpdateUserData {
name?: string;
bio?: string;
avatar?: string;
}
export interface LoginCredentials {
email: string;
password: string;
}
export class UserService {
static async createUser(userData: CreateUserData): Promise<User> {
// Check if user already exists
const existingUser = await User.findByEmail(userData.email);
if (existingUser) {
throw new Error('User with this email already exists');
}
// Hash password
const saltRounds = 10;
const passwordHash = await bcrypt.hash(userData.password, saltRounds);
// Create user
const user = new User();
user.name = userData.name;
user.email = userData.email;
user.passwordHash = passwordHash;
user.bio = userData.bio;
user.avatar = userData.avatar;
user.isActive = true;
user.createdAt = new Date().toISOString();
user.updatedAt = new Date().toISOString();
await user.save();
// Send welcome email
await emailService.sendWelcomeEmail(user);
return user;
}
static async authenticate(credentials: LoginCredentials): Promise<{ user: User; token: string }> {
const user = await User.findByEmail(credentials.email);
if (!user || !user.isActive) {
throw new Error('Invalid credentials');
}
if (!user.passwordHash) {
throw new Error('User has no password set');
}
const isPasswordValid = await bcrypt.compare(credentials.password, user.passwordHash);
if (!isPasswordValid) {
throw new Error('Invalid credentials');
}
// Update last login
await user.updateLastLogin();
// Generate JWT token
const token = jwt.sign(
{ userId: user.getId(), email: user.email },
process.env.JWT_SECRET!,
{ expiresIn: '24h' }
);
return { user, token };
}
static async getUserById(id: string): Promise<User | null> {
try {
const user = new User();
await user.load(id);
return user.isActive ? user : null;
} catch (error) {
return null;
}
}
static async updateUser(id: string, updates: UpdateUserData): Promise<User> {
const user = await this.getUserById(id);
if (!user) {
throw new Error('User not found');
}
user.initFromData(updates);
user.updatedAt = new Date().toISOString();
await user.save();
return user;
}
static async deleteUser(id: string): Promise<void> {
const user = await this.getUserById(id);
if (!user) {
throw new Error('User not found');
}
await user.softDelete();
}
static async getAllUsers(options: {
page?: number;
limit?: number;
includeInactive?: boolean;
} = {}): Promise<{ users: User[]; total: number; page: number; totalPages: number }> {
const { page = 1, limit = 10, includeInactive = false } = options;
const offset = (page - 1) * limit;
let query = User.query();
if (!includeInactive) {
query = query.where('isActive', '==', true);
}
// Get total count
const totalUsers = await User.count();
// Get paginated results
const users = await query
.orderBy('createdAt', 'desc')
.limit(limit)
.startAfter(offset)
.get();
const totalPages = Math.ceil(totalUsers / limit);
return {
users,
total: totalUsers,
page,
totalPages,
};
}
static async searchUsers(searchTerm: string): Promise<User[]> {
// Note: Firestore doesn't support full-text search natively
// This is a simple implementation - consider using Algolia or Elasticsearch for better search
const users = await User.findActive();
return users.filter(user =>
user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(searchTerm.toLowerCase())
);
}
static async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {
const user = await this.getUserById(userId);
if (!user || !user.passwordHash) {
throw new Error('User not found');
}
const isOldPasswordValid = await bcrypt.compare(oldPassword, user.passwordHash);
if (!isOldPasswordValid) {
throw new Error('Current password is incorrect');
}
const saltRounds = 10;
user.passwordHash = await bcrypt.hash(newPassword, saltRounds);
user.updatedAt = new Date().toISOString();
await user.save();
}
}
Post Service
// src/services/postService.ts
import { Post, PostStatus } from '../models/Post';
import { UserService } from './userService';
export interface CreatePostData {
title: string;
content: string;
excerpt?: string;
authorId: string;
tags?: string[];
status?: PostStatus;
}
export interface UpdatePostData {
title?: string;
content?: string;
excerpt?: string;
tags?: string[];
status?: PostStatus;
}
export class PostService {
static async createPost(postData: CreatePostData): Promise<Post> {
// Verify author exists
const author = await UserService.getUserById(postData.authorId);
if (!author) {
throw new Error('Author not found');
}
const post = new Post();
post.title = postData.title;
post.content = postData.content;
post.excerpt = postData.excerpt;
post.authorId = postData.authorId;
post.tags = postData.tags || [];
post.status = postData.status || PostStatus.DRAFT;
post.viewCount = 0;
post.createdAt = new Date().toISOString();
post.updatedAt = new Date().toISOString();
if (post.status === PostStatus.PUBLISHED) {
post.publishedAt = new Date().toISOString();
}
await post.save();
return post;
}
static async getPostById(id: string): Promise<Post | null> {
try {
const post = new Post();
await post.load(id);
return post;
} catch (error) {
return null;
}
}
static async updatePost(id: string, updates: UpdatePostData): Promise<Post> {
const post = await this.getPostById(id);
if (!post) {
throw new Error('Post not found');
}
const wasPublished = post.status === PostStatus.PUBLISHED;
post.initFromData(updates);
post.updatedAt = new Date().toISOString();
// Set published date if status changed to published
if (!wasPublished && post.status === PostStatus.PUBLISHED) {
post.publishedAt = new Date().toISOString();
}
await post.save();
return post;
}
static async deletePost(id: string): Promise<void> {
const post = await this.getPostById(id);
if (!post) {
throw new Error('Post not found');
}
await post.destroy();
}
static async getPublishedPosts(options: {
page?: number;
limit?: number;
authorId?: string;
tag?: string;
} = {}): Promise<{ posts: Post[]; total: number; page: number; totalPages: number }> {
const { page = 1, limit = 10, authorId, tag } = options;
const offset = (page - 1) * limit;
let query = Post.query().where('status', '==', PostStatus.PUBLISHED);
if (authorId) {
query = query.where('authorId', '==', authorId);
}
if (tag) {
query = query.where('tags', 'array-contains', tag);
}
// Get total count (approximate for pagination)
const totalPosts = await Post.count();
// Get paginated results
const posts = await query
.orderBy('publishedAt', 'desc')
.limit(limit)
.startAfter(offset)
.get();
const totalPages = Math.ceil(totalPosts / limit);
return {
posts,
total: totalPosts,
page,
totalPages,
};
}
static async getPostsByAuthor(authorId: string): Promise<Post[]> {
return await Post.findByAuthor(authorId);
}
static async getPostsByTag(tag: string): Promise<Post[]> {
return await Post.findByTag(tag);
}
static async publishPost(id: string): Promise<Post> {
const post = await this.getPostById(id);
if (!post) {
throw new Error('Post not found');
}
await post.publish();
return post;
}
static async incrementPostViews(id: string): Promise<Post> {
const post = await this.getPostById(id);
if (!post) {
throw new Error('Post not found');
}
await post.incrementViewCount();
return post;
}
static async searchPosts(searchTerm: string): Promise<Post[]> {
// Simple search implementation
const posts = await Post.findPublished();
return posts.filter(post =>
post.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
post.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
(post.tags && post.tags.some(tag =>
tag.toLowerCase().includes(searchTerm.toLowerCase())
))
);
}
}
Controllers
User Controller
// src/controllers/userController.ts
import { Request, Response, NextFunction } from 'express';
import { UserService } from '../services/userService';
import { validationResult } from 'express-validator';
export class UserController {
static async createUser(req: Request, res: Response, next: NextFunction) {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validation failed',
errors: errors.array(),
});
}
const user = await UserService.createUser(req.body);
res.status(201).json({
success: true,
message: 'User created successfully',
data: user.toJSON(),
});
} catch (error) {
next(error);
}
}
static async login(req: Request, res: Response, next: NextFunction) {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validation failed',
errors: errors.array(),
});
}
const { user, token } = await UserService.authenticate(req.body);
res.json({
success: true,
message: 'Login successful',
data: {
user: user.toJSON(),
token,
},
});
} catch (error) {
if (error.message === 'Invalid credentials') {
return res.status(401).json({
success: false,
message: 'Invalid email or password',
});
}
next(error);
}
}
static async getUsers(req: Request, res: Response, next: NextFunction) {
try {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
const includeInactive = req.query.includeInactive === 'true';
const result = await UserService.getAllUsers({ page, limit, includeInactive });
res.json({
success: true,
message: 'Users retrieved successfully',
data: {
users: result.users.map(user => user.toPublicJSON()),
pagination: {
page: result.page,
limit,
total: result.total,
totalPages: result.totalPages,
},
},
});
} catch (error) {
next(error);
}
}
static async getUserById(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.params;
const user = await UserService.getUserById(id);
if (!user) {
return res.status(404).json({
success: false,
message: 'User not found',
});
}
res.json({
success: true,
message: 'User retrieved successfully',
data: user.toPublicJSON(),
});
} catch (error) {
next(error);
}
}
static async updateUser(req: Request, res: Response, next: NextFunction) {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validation failed',
errors: errors.array(),
});
}
const { id } = req.params;
// Check if user is updating their own profile or has admin rights
if (req.user?.userId !== id && !req.user?.isAdmin) {
return res.status(403).json({
success: false,
message: 'Forbidden: Can only update your own profile',
});
}
const user = await UserService.updateUser(id, req.body);
res.json({
success: true,
message: 'User updated successfully',
data: user.toJSON(),
});
} catch (error) {
if (error.message === 'User not found') {
return res.status(404).json({
success: false,
message: 'User not found',
});
}
next(error);
}
}
static async deleteUser(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.params;
// Check if user is deleting their own account or has admin rights
if (req.user?.userId !== id && !req.user?.isAdmin) {
return res.status(403).json({
success: false,
message: 'Forbidden: Can only delete your own account',
});
}
await UserService.deleteUser(id);
res.json({
success: true,
message: 'User deleted successfully',
});
} catch (error) {
if (error.message === 'User not found') {
return res.status(404).json({
success: false,
message: 'User not found',
});
}
next(error);
}
}
static async searchUsers(req: Request, res: Response, next: NextFunction) {
try {
const { q } = req.query;
if (!q || typeof q !== 'string') {
return res.status(400).json({
success: false,
message: 'Search query is required',
});
}
const users = await UserService.searchUsers(q);
res.json({
success: true,
message: 'Search completed successfully',
data: users.map(user => user.toPublicJSON()),
});
} catch (error) {
next(error);
}
}
static async changePassword(req: Request, res: Response, next: NextFunction) {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validation failed',
errors: errors.array(),
});
}
const { id } = req.params;
const { oldPassword, newPassword } = req.body;
// Check if user is changing their own password
if (req.user?.userId !== id) {
return res.status(403).json({
success: false,
message: 'Forbidden: Can only change your own password',
});
}
await UserService.changePassword(id, oldPassword, newPassword);
res.json({
success: true,
message: 'Password changed successfully',
});
} catch (error) {
if (error.message === 'Current password is incorrect') {
return res.status(400).json({
success: false,
message: 'Current password is incorrect',
});
}
next(error);
}
}
}
Routes
User Routes
// src/routes/userRoutes.ts
import { Router } from 'express';
import { body, param } from 'express-validator';
import { UserController } from '../controllers/userController';
import { authMiddleware } from '../middleware/auth';
import { adminMiddleware } from '../middleware/auth';
const router = Router();
// Validation rules
const createUserValidation = [
body('name').trim().isLength({ min: 2, max: 50 }).withMessage('Name must be 2-50 characters'),
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters'),
body('bio').optional().isLength({ max: 500 }).withMessage('Bio must be less than 500 characters'),
body('avatar').optional().isURL().withMessage('Avatar must be a valid URL'),
];
const loginValidation = [
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
body('password').notEmpty().withMessage('Password is required'),
];
const updateUserValidation = [
body('name').optional().trim().isLength({ min: 2, max: 50 }).withMessage('Name must be 2-50 characters'),
body('bio').optional().isLength({ max: 500 }).withMessage('Bio must be less than 500 characters'),
body('avatar').optional().isURL().withMessage('Avatar must be a valid URL'),
];
const changePasswordValidation = [
body('oldPassword').notEmpty().withMessage('Current password is required'),
body('newPassword').isLength({ min: 6 }).withMessage('New password must be at least 6 characters'),
];
const userIdValidation = [
param('id').isLength({ min: 1 }).withMessage('User ID is required'),
];
// Public routes
router.post('/register', createUserValidation, UserController.createUser);
router.post('/login', loginValidation, UserController.login);
// Protected routes
router.use(authMiddleware); // Apply auth middleware to all routes below
router.get('/search', UserController.searchUsers);
router.get('/', UserController.getUsers);
router.get('/:id', userIdValidation, UserController.getUserById);
router.put('/:id', [...userIdValidation, ...updateUserValidation], UserController.updateUser);
router.delete('/:id', userIdValidation, UserController.deleteUser);
router.post('/:id/change-password', [...userIdValidation, ...changePasswordValidation], UserController.changePassword);
export { router as userRoutes };
Middleware
Authentication Middleware
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { UserService } from '../services/userService';
// Extend Express Request type
declare global {
namespace Express {
interface Request {
user?: {
userId: string;
email: string;
isAdmin?: boolean;
};
}
}
}
export const authMiddleware = async (req: Request, res: Response, next: NextFunction) => {
try {
const token = req.header('Authorization')?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({
success: false,
message: 'Access denied. No token provided.',
});
}
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
userId: string;
email: string;
};
// Verify user still exists and is active
const user = await UserService.getUserById(decoded.userId);
if (!user) {
return res.status(401).json({
success: false,
message: 'Access denied. User not found.',
});
}
// Check if user has admin role (assuming roles are loaded)
const isAdmin = await user.hasRole('admin');
req.user = {
userId: decoded.userId,
email: decoded.email,
isAdmin,
};
next();
} catch (error) {
return res.status(401).json({
success: false,
message: 'Access denied. Invalid token.',
});
}
};
export const adminMiddleware = (req: Request, res: Response, next: NextFunction) => {
if (!req.user?.isAdmin) {
return res.status(403).json({
success: false,
message: 'Access denied. Admin rights required.',
});
}
next();
};
export const optionalAuthMiddleware = async (req: Request, res: Response, next: NextFunction) => {
try {
const token = req.header('Authorization')?.replace('Bearer ', '');
if (token) {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
userId: string;
email: string;
};
const user = await UserService.getUserById(decoded.userId);
if (user) {
const isAdmin = await user.hasRole('admin');
req.user = {
userId: decoded.userId,
email: decoded.email,
isAdmin,
};
}
}
next();
} catch (error) {
// Continue without authentication if token is invalid
next();
}
};
Error Handler
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { logger } from '../utils/logger';
export interface AppError extends Error {
statusCode?: number;
isOperational?: boolean;
}
export const errorHandler = (
error: AppError,
req: Request,
res: Response,
next: NextFunction
) => {
let statusCode = error.statusCode || 500;
let message = error.message || 'Internal Server Error';
// Log error
logger.error('Error:', {
error: error.message,
stack: error.stack,
url: req.url,
method: req.method,
ip: req.ip,
userAgent: req.get('User-Agent'),
});
// Firestore errors
if (error.message.includes('permission-denied')) {
statusCode = 403;
message = 'Permission denied';
} else if (error.message.includes('not-found')) {
statusCode = 404;
message = 'Resource not found';
} else if (error.message.includes('already-exists')) {
statusCode = 409;
message = 'Resource already exists';
}
// JWT errors
if (error.name === 'JsonWebTokenError') {
statusCode = 401;
message = 'Invalid token';
} else if (error.name === 'TokenExpiredError') {
statusCode = 401;
message = 'Token expired';
}
// Validation errors
if (error.name === 'ValidationError') {
statusCode = 400;
message = 'Validation failed';
}
// Don't expose internal errors in production
if (process.env.NODE_ENV === 'production' && statusCode === 500) {
message = 'Internal Server Error';
}
res.status(statusCode).json({
success: false,
message,
...(process.env.NODE_ENV === 'development' && { stack: error.stack }),
});
};
export const notFoundHandler = (req: Request, res: Response) => {
res.status(404).json({
success: false,
message: `Route ${req.originalUrl} not found`,
});
};
Express App Setup
Main App
// src/app.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import rateLimit from 'express-rate-limit';
import { userRoutes } from './routes/userRoutes';
import { postRoutes } from './routes/postRoutes';
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
import { logger } from './utils/logger';
// Initialize Firebase
import './config/firebase';
const app = express();
// Security middleware
app.use(helmet());
// CORS configuration
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
credentials: true,
}));
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: {
success: false,
message: 'Too many requests from this IP, please try again later.',
},
});
app.use(limiter);
// Body parsing middleware
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Logging middleware
app.use(morgan('combined', {
stream: {
write: (message) => logger.info(message.trim()),
},
}));
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
success: true,
message: 'Server is running',
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV,
});
});
// API routes
app.use('/api/users', userRoutes);
app.use('/api/posts', postRoutes);
// Error handling
app.use(notFoundHandler);
app.use(errorHandler);
export default app;
Server
// src/server.ts
import dotenv from 'dotenv';
import app from './app';
import { logger } from './utils/logger';
// Load environment variables
dotenv.config();
const PORT = process.env.PORT || 3000;
// Handle uncaught exceptions
process.on('uncaughtException', (error) => {
logger.error('Uncaught Exception:', error);
process.exit(1);
});
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
process.exit(1);
});
// Start server
const server = app.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
logger.info(`Environment: ${process.env.NODE_ENV}`);
});
// Graceful shutdown
process.on('SIGTERM', () => {
logger.info('SIGTERM received. Shutting down gracefully...');
server.close(() => {
logger.info('Process terminated');
process.exit(0);
});
});
process.on('SIGINT', () => {
logger.info('SIGINT received. Shutting down gracefully...');
server.close(() => {
logger.info('Process terminated');
process.exit(0);
});
});
Utilities
Logger
// src/utils/logger.ts
import winston from 'winston';
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: 'firebase-orm-api' },
transports: [
// Write all logs with level 'error' and below to 'error.log'
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
// Write all logs with level 'info' and below to 'combined.log'
new winston.transports.File({ filename: 'logs/combined.log' }),
],
});
// If we're not in production, log to the console as well
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}));
}
export { logger };
Testing
Jest Setup
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.test.ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/server.ts',
],
setupFilesAfterEnv: ['<rootDir>/src/test/setup.ts'],
};
// src/test/setup.ts
import { jest } from '@jest/globals';
// Mock Firebase Admin
jest.mock('firebase-admin', () => ({
initializeApp: jest.fn(),
firestore: jest.fn(() => ({
collection: jest.fn(),
doc: jest.fn(),
})),
credential: {
cert: jest.fn(),
applicationDefault: jest.fn(),
},
apps: [],
}));
// Mock Firebase ORM
jest.mock('@arbel/firebase-orm', () => ({
FirestoreOrmRepository: {
initGlobalConnection: jest.fn(),
},
BaseModel: class MockBaseModel {
save = jest.fn();
load = jest.fn();
destroy = jest.fn();
getId = jest.fn(() => 'mock-id');
static getAll = jest.fn(() => Promise.resolve([]));
static query = jest.fn(() => ({
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
startAfter: jest.fn().mockReturnThis(),
get: jest.fn(() => Promise.resolve([])),
}));
static count = jest.fn(() => Promise.resolve(0));
},
Model: () => (target: any) => target,
Field: () => (target: any, propertyKey: string) => {},
HasMany: () => (target: any, propertyKey: string) => {},
BelongsTo: () => (target: any, propertyKey: string) => {},
}));
Service Tests
// src/services/__tests__/userService.test.ts
import { UserService } from '../userService';
import { User } from '../../models/User';
describe('UserService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('createUser', () => {
it('should create a new user successfully', async () => {
const userData = {
name: 'John Doe',
email: 'john@example.com',
password: 'password123',
};
// Mock User.findByEmail to return null (user doesn't exist)
jest.spyOn(User, 'findByEmail').mockResolvedValue(null);
// Mock user save
const mockUser = new User();
jest.spyOn(mockUser, 'save').mockResolvedValue();
const result = await UserService.createUser(userData);
expect(result).toBeInstanceOf(User);
expect(result.name).toBe(userData.name);
expect(result.email).toBe(userData.email);
});
it('should throw error if user already exists', async () => {
const userData = {
name: 'John Doe',
email: 'john@example.com',
password: 'password123',
};
// Mock User.findByEmail to return existing user
const existingUser = new User();
jest.spyOn(User, 'findByEmail').mockResolvedValue(existingUser);
await expect(UserService.createUser(userData)).rejects.toThrow(
'User with this email already exists'
);
});
});
describe('authenticate', () => {
it('should authenticate user with valid credentials', async () => {
const credentials = {
email: 'john@example.com',
password: 'password123',
};
const mockUser = new User();
mockUser.email = credentials.email;
mockUser.passwordHash = 'hashedpassword';
mockUser.isActive = true;
jest.spyOn(User, 'findByEmail').mockResolvedValue(mockUser);
jest.spyOn(mockUser, 'updateLastLogin').mockResolvedValue();
// Mock bcrypt compare
jest.doMock('bcrypt', () => ({
compare: jest.fn().mockResolvedValue(true),
}));
const result = await UserService.authenticate(credentials);
expect(result.user).toBe(mockUser);
expect(result.token).toBeDefined();
});
});
});
Docker Setup
Dockerfile
# Dockerfile
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci --only=production
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:18-alpine AS production
WORKDIR /app
# Copy package files and install dependencies
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# Copy built application
COPY --from=builder /app/dist ./dist
# Create non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nodejs -u 1001
# Change ownership
RUN chown -R nodejs:nodejs /app
USER nodejs
EXPOSE 3000
CMD ["node", "dist/server.js"]
Docker Compose
# docker-compose.yml
version: '3.8'
services:
api:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- FIREBASE_PROJECT_ID=${FIREBASE_PROJECT_ID}
- FIREBASE_CLIENT_EMAIL=${FIREBASE_CLIENT_EMAIL}
- FIREBASE_PRIVATE_KEY=${FIREBASE_PRIVATE_KEY}
- JWT_SECRET=${JWT_SECRET}
volumes:
- ./logs:/app/logs
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./ssl:/etc/ssl/certs
depends_on:
- api
restart: unless-stopped
Deployment
Package.json Scripts
{
"scripts": {
"start": "node dist/server.js",
"dev": "nodemon src/server.ts",
"build": "tsc",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"lint": "eslint src/**/*.ts",
"lint:fix": "eslint src/**/*.ts --fix",
"docker:build": "docker build -t firebase-orm-api .",
"docker:run": "docker run -p 3000:3000 firebase-orm-api"
}
}
CI/CD Example (GitHub Actions)
# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: npm ci
- run: npm test
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Deploy to server
run: |
# Add your deployment commands here
echo "Deploying to production..."
Best Practices
1. Environment Management
// src/config/env.ts
import dotenv from 'dotenv';
dotenv.config();
interface Config {
port: number;
nodeEnv: string;
firebase: {
projectId: string;
clientEmail: string;
privateKey: string;
};
jwt: {
secret: string;
expiresIn: string;
};
}
export const config: Config = {
port: parseInt(process.env.PORT || '3000'),
nodeEnv: process.env.NODE_ENV || 'development',
firebase: {
projectId: process.env.FIREBASE_PROJECT_ID!,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL!,
privateKey: process.env.FIREBASE_PRIVATE_KEY!.replace(/\\n/g, '\n'),
},
jwt: {
secret: process.env.JWT_SECRET!,
expiresIn: process.env.JWT_EXPIRES_IN || '24h',
},
};
// Validate required environment variables
const requiredEnvVars = [
'FIREBASE_PROJECT_ID',
'FIREBASE_CLIENT_EMAIL',
'FIREBASE_PRIVATE_KEY',
'JWT_SECRET',
];
requiredEnvVars.forEach((envVar) => {
if (!process.env[envVar]) {
throw new Error(`Missing required environment variable: ${envVar}`);
}
});
2. Database Transactions
// Example of transaction usage with Firebase ORM
import { admin } from '../config/firebase';
export class TransactionService {
static async transferData(fromId: string, toId: string, amount: number) {
const db = admin.firestore();
return await db.runTransaction(async (transaction) => {
// Read operations must come before write operations
const fromUser = new User();
await fromUser.load(fromId);
const toUser = new User();
await toUser.load(toId);
if (fromUser.balance < amount) {
throw new Error('Insufficient balance');
}
// Update balances
fromUser.balance -= amount;
toUser.balance += amount;
// Save within transaction
await fromUser.save();
await toUser.save();
return { fromUser, toUser };
});
}
}
3. Caching Strategy
// Simple in-memory cache example
class CacheService {
private cache = new Map<string, { data: any; expires: number }>();
set(key: string, data: any, ttlSeconds: number = 300) {
const expires = Date.now() + (ttlSeconds * 1000);
this.cache.set(key, { data, expires });
}
get(key: string): any | null {
const item = this.cache.get(key);
if (!item) return null;
if (Date.now() > item.expires) {
this.cache.delete(key);
return null;
}
return item.data;
}
delete(key: string) {
this.cache.delete(key);
}
clear() {
this.cache.clear();
}
}
export const cache = new CacheService();
Common Issues
1. Firebase Admin Initialization
Ensure Firebase Admin is initialized before using Firebase ORM.
2. Environment Variables
Use proper environment variable validation and defaults.
3. Error Handling
Implement comprehensive error handling for all async operations.
4. Memory Leaks
Be careful with real-time subscriptions and event listeners.
Next Steps
- Learn about Real-time Features
- Explore Performance Optimization
- Check Security Best Practices