feat: initialize finance project with Fastify, Prisma, and Zod

- Added package.json with necessary dependencies and scripts for development and production.
- Created Prisma schema for BankAccount model with fields: id, name, bankName, accountNumber, createdAt, and updatedAt.
- Implemented Fastify server with CRUD operations for bank accounts, including validation using Zod.
- Added graceful shutdown handling for server and Prisma client.
This commit is contained in:
Peter Wood
2025-04-05 17:52:40 -04:00
parent 7c1cf427d1
commit d150757025
14 changed files with 2266 additions and 710 deletions

411
overview.md Normal file
View File

@@ -0,0 +1,411 @@
Okay, let's set up a skeleton Fastify API project with Prisma to interact with a PostgreSQL database. This structure will provide the requested endpoints.
**Assumptions:**
1. You have Node.js and npm (or yarn) installed.
2. You have PostgreSQL installed and running locally.
3. You have a PostgreSQL database created (e.g., `finance_db`).
4. You have a PostgreSQL user with privileges on that database.
**Project Setup Steps:**
1. **Create Project Directory & Initialize:**
```bash
mkdir finance-api
cd finance-api
npm init -y
```
2. **Install Dependencies:**
```bash
# Runtime dependencies
npm install fastify @prisma/client dotenv
# Development dependencies
npm install --save-dev prisma typescript @types/node ts-node nodemon
# Or if you prefer plain Javascript, skip typescript/ts-node and adjust scripts
```
* `fastify`: The web framework.
* `@prisma/client`: The Prisma database client.
* `dotenv`: To load environment variables from a `.env` file.
* `prisma`: The Prisma CLI (for migrations, generation).
* `typescript`, `@types/node`, `ts-node`: For TypeScript support (recommended).
* `nodemon`: To automatically restart the server during development.
3. **Initialize Prisma:**
```bash
npx prisma init --datasource-provider postgresql
```
* This creates a `prisma` directory with a `schema.prisma` file and a `.env` file.
4. **Configure `.env`:**
Edit the newly created `.env` file and set your PostgreSQL connection URL:
```dotenv
# .env
# Replace with your actual database user, password, host, port, and database name
DATABASE_URL="postgresql://YOUR_USER:YOUR_PASSWORD@localhost:5432/finance_db?schema=public"
# API Server Configuration (Optional, but good practice)
API_HOST=0.0.0.0
API_PORT=3050
API_BASE_URL=https://finance.ptrwd.com # Used for documentation/reference, not binding
```
5. **Define Prisma Schema (`prisma/schema.prisma`):**
Define the model for your bank accounts.
```prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model BankAccount {
id Int @id @default(autoincrement())
name String // e.g., "Checking Account", "Savings XYZ"
bankName String // e.g., "Chase", "Wells Fargo"
accountNumber String @unique // Consider encryption in a real app
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("bank_accounts") // Optional: specify table name in snake_case
}
```
6. **Create Initial Database Migration:**
```bash
npx prisma migrate dev --name init_bank_account
```
* This command does two things:
* Creates an SQL migration file in `prisma/migrations`.
* Applies the migration to your database, creating the `bank_accounts` table.
7. **Generate Prisma Client:**
Ensure the client is generated based on your schema:
```bash
npx prisma generate
```
* This command reads your `schema.prisma` and generates the typed `@prisma/client`. You typically run this after any schema change.
8. **Create the Fastify Server (`src/server.ts` or `server.js`):**
```typescript
// src/server.ts (if using TypeScript)
// If using Javascript, remove type annotations and use require instead of import
import Fastify, { FastifyInstance, RouteShorthandOptions } from 'fastify';
import { Server, IncomingMessage, ServerResponse } from 'http';
import { PrismaClient, BankAccount } from '@prisma/client';
import dotenv from 'dotenv';
// Load environment variables
dotenv.config();
const prisma = new PrismaClient();
const server: FastifyInstance = Fastify({
logger: true // Enable logging
});
// --- Type Definitions for Payloads/Params (Good Practice) ---
interface BankAccountCreateParams {
name: string;
bankName: string;
accountNumber: string;
}
interface BankAccountUpdateParams {
id: string;
}
interface BankAccountUpdateBody {
name?: string;
bankName?: string;
accountNumber?: string;
}
interface BankAccountDeleteParams {
id: string;
}
// --- API Routes ---
const API_PREFIX = '/api/bank-account';
// 1. Create Bank Account
server.post<{ Body: BankAccountCreateParams }>(
`${API_PREFIX}/create`,
async (request, reply): Promise<BankAccount> => {
try {
const { name, bankName, accountNumber } = request.body;
const newAccount = await prisma.bankAccount.create({
data: {
name,
bankName,
accountNumber,
},
});
reply.code(201); // Resource Created
return newAccount;
} catch (error: any) {
server.log.error(error);
// Basic duplicate check example (Prisma throws P2002)
if (error.code === 'P2002' && error.meta?.target?.includes('accountNumber')) {
reply.code(409); // Conflict
throw new Error(`Bank account with number ${request.body.accountNumber} already exists.`);
}
reply.code(500);
throw new Error('Failed to create bank account.');
}
}
);
// 2. Update Bank Account
server.post<{ Params: BankAccountUpdateParams; Body: BankAccountUpdateBody }>(
`${API_PREFIX}/update/:id`,
async (request, reply): Promise<BankAccount> => {
try {
const { id } = request.params;
const updateData = request.body;
// Ensure ID is a valid number before querying
const accountId = parseInt(id, 10);
if (isNaN(accountId)) {
reply.code(400);
throw new Error('Invalid account ID format.');
}
const updatedAccount = await prisma.bankAccount.update({
where: { id: accountId },
data: updateData,
});
return updatedAccount;
} catch (error: any) {
server.log.error(error);
// Handle case where account doesn't exist (Prisma throws P2025)
if (error.code === 'P2025') {
reply.code(404); // Not Found
throw new Error(`Bank account with ID ${request.params.id} not found.`);
}
// Handle potential duplicate account number on update
if (error.code === 'P2002' && error.meta?.target?.includes('accountNumber')) {
reply.code(409); // Conflict
throw new Error(`Bank account with number ${request.body.accountNumber} already exists.`);
}
reply.code(500);
throw new Error('Failed to update bank account.');
}
}
);
// 3. Delete Bank Account
server.delete<{ Params: BankAccountDeleteParams }>(
`${API_PREFIX}/delete/:id`,
async (request, reply): Promise<{ message: string; deletedAccount: BankAccount }> => {
try {
const { id } = request.params;
// Ensure ID is a valid number
const accountId = parseInt(id, 10);
if (isNaN(accountId)) {
reply.code(400);
throw new Error('Invalid account ID format.');
}
const deletedAccount = await prisma.bankAccount.delete({
where: { id: accountId },
});
return { message: `Bank account with ID ${id} deleted successfully.`, deletedAccount };
} catch (error: any) {
server.log.error(error);
// Handle case where account doesn't exist (Prisma throws P2025)
if (error.code === 'P2025') {
reply.code(404); // Not Found
throw new Error(`Bank account with ID ${request.params.id} not found.`);
}
reply.code(500);
throw new Error('Failed to delete bank account.');
}
}
);
// 4. Get All Bank Accounts
server.get(
`${API_PREFIX}/`, // Using trailing slash for consistency, Fastify often handles both
async (request, reply): Promise<BankAccount[]> => {
try {
const accounts = await prisma.bankAccount.findMany({
orderBy: { // Optional: Order results
createdAt: 'desc'
}
});
return accounts;
} catch (error: any) {
server.log.error(error);
reply.code(500);
throw new Error('Failed to retrieve bank accounts.');
}
}
);
// Optional: Get Single Bank Account by ID (Often useful)
server.get<{ Params: { id: string } }>(
`${API_PREFIX}/:id`,
async (request, reply): Promise<BankAccount> => {
try {
const { id } = request.params;
const accountId = parseInt(id, 10);
if (isNaN(accountId)) {
reply.code(400);
throw new Error('Invalid account ID format.');
}
const account = await prisma.bankAccount.findUnique({
where: { id: accountId },
});
if (!account) {
reply.code(404);
throw new Error(`Bank account with ID ${id} not found.`);
}
return account;
} catch (error: any) {
// Catch errors that might have been thrown before prisma query
if (!reply.sent) { // Check if a reply hasn't already been sent (e.g., by 400 error)
server.log.error(error);
reply.code(500);
throw new Error('Failed to retrieve bank account.');
}
// If reply already sent, just rethrow the original error
throw error;
}
}
);
// --- Graceful Shutdown ---
const gracefulShutdown = async (signal: string) => {
console.log(`*^! Received signal ${signal}. Shutting down...`);
try {
await server.close();
console.log('Fastify server closed.');
await prisma.$disconnect();
console.log('Prisma client disconnected.');
process.exit(0);
} catch (err) {
console.error('Error during shutdown:', err);
process.exit(1);
}
};
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
// --- Start Server ---
const start = async () => {
try {
const host = process.env.API_HOST || '0.0.0.0';
const port = parseInt(process.env.API_PORT || '3050', 10);
await server.listen({ port, host });
// Log after listen resolves (Fastify v3+)
// server.log.info(`Server listening at http://${host}:${port}`);
// Note: Fastify logger automatically logs listening address
} catch (err) {
server.log.error(err);
await prisma.$disconnect(); // Ensure prisma disconnects on startup error too
process.exit(1);
}
};
start();
// Export prisma instance if needed elsewhere (e.g., tests, scripts)
// export { prisma };
```
9. **Configure `tsconfig.json` (if using TypeScript):**
Create a `tsconfig.json` file in the root directory:
```json
// tsconfig.json
{
"compilerOptions": {
"target": "ES2017", // Or newer
"module": "CommonJS",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"sourceMap": true, // Helpful for debugging
"resolveJsonModule": true // Allows importing JSON files
},
"include": ["src/**/*"], // Only compile files in src
"exclude": ["node_modules", "**/*.spec.ts"] // Exclude tests if you have them
}
```
10. **Add Scripts to `package.json`:**
```json
// package.json (add or modify the scripts section)
"scripts": {
"build": "tsc", // Compile TypeScript to JavaScript (if using TS)
"start": "node dist/server.js", // Run compiled JS (if using TS)
"dev": "nodemon --watch src --ext ts --exec ts-node src/server.ts", // Dev mode with TS
// --- OR if using plain JavaScript ---
// "start": "node server.js",
// "dev": "nodemon server.js",
// --- Prisma commands for convenience ---
"prisma:migrate": "prisma migrate dev",
"prisma:generate": "prisma generate",
"prisma:studio": "prisma studio" // GUI to view/edit data
},
```
11. **Run the Development Server:**
```bash
npm run dev
```
**How to Test (using `curl`):**
*(Replace `YOUR_ACCOUNT_ID` with an actual ID after creating an account)*
* **Create:**
```bash
curl -X POST -H "Content-Type: application/json" \
-d '{"name": "My Checking", "bankName": "Local Bank", "accountNumber": "123456789"}' \
http://localhost:3050/api/bank-account/create
```
* **Get All:**
```bash
curl http://localhost:3050/api/bank-account/
```
* **Update (using the ID returned from create):**
```bash
curl -X POST -H "Content-Type: application/json" \
-d '{"name": "My Primary Checking"}' \
http://localhost:3050/api/bank-account/update/YOUR_ACCOUNT_ID
```
* **Delete (using the ID):**
```bash
curl -X DELETE http://localhost:3050/api/bank-account/delete/YOUR_ACCOUNT_ID
```
* **Get Specific (using the ID):**
```bash
curl http://localhost:3050/api/bank-account/YOUR_ACCOUNT_ID
```
This skeleton provides the core structure. You can build upon this by adding more robust error handling, input validation (using Fastify's built-in schema validation), authentication/authorization, more complex queries, and organizing routes into separate files/plugins as the application grows.