A REST API for airline inventory and flight search, built with Express and Sequelize on MySQL.
The service owns the reference data an airline booking platform needs (cities, airports, airplanes, seats) and exposes a filterable flight-search endpoint plus a concurrency-safe seat inventory operation.
The service follows a strict layered flow. Each layer talks only to the layer directly beneath it.
HTTP request
|
v
Router src/routes/ URL to handler mapping, API versioning
|
v
Middleware src/middlewares/ Request-shape validation, short-circuits with 400
|
v
Controller src/controllers/ HTTP boundary: reads req, calls service, writes res
|
v
Service src/services/ Business rules, cross-entity validation, error mapping
|
v
Repository src/repositories/ All data access; the only layer that knows Sequelize
|
v
Model src/models/ Schema definitions and associations
|
v
MySQL
---
npm installCreate .env at the project root:
PORT=3000
Create src/config/config.json (see Configuration for the template) and create the target schema in MySQL:
CREATE DATABASE flights_dev;cd src
npx sequelize db:migrate
npx sequelize db:seed:all
cd ..npm run devVerify the service is up:
curl http://localhost:3000/api/v1/info{ "success": true, "message": "API is live", "error": {}, "data": {} }| Variable | Required | Default | Description |
|---|---|---|---|
PORT |
yes | none | HTTP port the server binds to |
NODE_ENV |
no | development |
Selects the block used from src/config/config.json |
src/config/config.json holds credentials and is gitignored. Create it from this template:
{
"development": {
"username": "root",
"password": "your-password",
"database": "flights_dev",
"host": "127.0.0.1",
"dialect": "mysql"
},
"test": {
"username": "root",
"password": "your-password",
"database": "flights_test",
"host": "127.0.0.1",
"dialect": "mysql"
},
"production": {
"username": "root",
"password": "your-password",
"database": "flights_prod",
"host": "your-db-host",
"dialect": "mysql"
}
}Never commit this file. .gitignore already excludes .env, node_modules/, combine.log, and src/config/config.json.
Migrations and seeders live under src/, and there is no .sequelizerc at the root, so all sequelize-cli commands must be run from inside src/.
| Command | Purpose |
|---|---|
npx sequelize db:migrate |
Apply all pending migrations |
npx sequelize db:migrate:undo |
Roll back the most recent migration |
npx sequelize db:migrate:undo:all |
Roll back everything |
npx sequelize db:seed:all |
Run every seeder |
npx sequelize migration:generate --name <name> |
Scaffold a new migration |
npx sequelize seed:generate --name <name> |
Scaffold a new seeder |
Migration order matters and is encoded in the filename timestamps:
create-airplanecreate-citycreate-airportupdate-city-airport-association— adds thecity_fkey_constraintforeign keycreate-flightcreate-seat
Every migration implements a working down, so the schema is reversible.
All routes are prefixed with /api/v1. A placeholder /api/v2/info exists for the next contract version.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/info |
Liveness check |
GET |
/api/v2/info |
v2 placeholder |
Base path: /api/v1/city
| Method | Endpoint | Body | Description |
|---|---|---|---|
POST |
/city |
{ name } |
Create a city. name is required and unique |
GET |
/city |
— | List all cities |
GET |
/city/:id |
— | Fetch one city |
PATCH |
/city/:id |
{ name } |
Update a city |
DELETE |
/city/:id |
— | Delete a city and cascade to its airports |
curl -X POST http://localhost:3000/api/v1/city \
-H "Content-Type: application/json" \
-d '{"name":"Mumbai"}'Base path: /api/v1/airports
| Method | Endpoint | Body | Description |
|---|---|---|---|
POST |
/airports |
{ name, code, address, cityId } |
Create an airport. All four fields are required; name and code are unique |
GET |
/airports |
— | List all airports |
GET |
/airports/:id |
— | Fetch one airport |
PATCH |
/airports/:id |
partial | Update an airport |
DELETE |
/airports/:id |
— | Delete an airport |
curl -X POST http://localhost:3000/api/v1/airports \
-H "Content-Type: application/json" \
-d '{"name":"Chhatrapati Shivaji International","code":"MUM","address":"Andheri East, Mumbai","cityId":1}'Base path: /api/v1/airplanes
| Method | Endpoint | Body | Description |
|---|---|---|---|
POST |
/airplanes |
{ modelNumber, capacity } |
Create an airplane. modelNumber is required; capacity must not exceed 1200 |
GET |
/airplanes |
— | List all airplanes |
GET |
/airplanes/:id |
— | Fetch one airplane |
PATCH |
/airplanes/:id |
{ modelNumber, capacity } |
Update an airplane |
DELETE |
/airplanes/:id |
— | Delete an airplane and cascade to its flights and seats |
curl -X POST http://localhost:3000/api/v1/airplanes \
-H "Content-Type: application/json" \
-d '{"modelNumber":"airbus-a380","capacity":400}'Base path: /api/v1/flights
| Method | Endpoint | Description |
|---|---|---|
POST |
/flights |
Create a flight |
GET |
/flights |
Search flights with filters and sorting |
GET |
/flights/:id |
Fetch one flight |
PATCH |
/flights/:id/seats |
Decrement or increment remaining seats |
Required fields: flightNumber, airplaneId, departureAirportId, arrivalAirportId, arrivalTime, departureTime, price, totalSeats.
curl -X POST http://localhost:3000/api/v1/flights \
-H "Content-Type: application/json" \
-d '{
"flightNumber": "UK-808",
"airplaneId": 1,
"departureAirportId": "MUM",
"arrivalAirportId": "DEL",
"departureTime": "2026-09-01 09:00:00",
"arrivalTime": "2026-09-01 11:10:00",
"price": 4500,
"boardingGate": "12A",
"totalSeats": 180
}'Business rules enforced by the service layer:
- Arrival time must be strictly later than departure time.
- Departure and arrival airports must differ.
| Query parameter | Format | Example | Effect |
|---|---|---|---|
trips |
<DEP>-<ARR> |
trips=MUM-DEL |
Filters by departure and arrival airport codes |
price |
<min>-<max> |
price=2000-8000 |
Price band. Omitting the max defaults it to 20000 |
travellers |
integer | travellers=4 |
Only flights with at least this many remaining seats |
tripDate |
YYYY-MM-DD |
tripDate=2026-09-01 |
Departures within that calendar day |
sort |
<field>_<dir>,... |
sort=price_ASC,departureTime_DESC |
Multi-column ordering |
curl "http://localhost:3000/api/v1/flights?trips=MUM-DEL&price=2000-8000&travellers=2&tripDate=2026-09-01&sort=price_ASC,departureTime_DESC"Results are returned fully hydrated: each flight embeds airplaneDetail, plus departureAirport and arrivalAirport, each with its parent City. The airport joins are custom ON clauses matching Flight.departureAirportId against Airport.code rather than the default numeric key.
curl -X PATCH http://localhost:3000/api/v1/flights/1/seats \
-H "Content-Type: application/json" \
-d '{"seats":2,"dec":true}'| Field | Type | Required | Description |
|---|---|---|---|
seats |
integer | yes | Number of seats to move |
dec |
boolean | no (defaults to true) |
true books seats, false releases them |
Every endpoint returns the same envelope, so clients need exactly one parser.
Success
{
"success": true,
"message": "Successfully completed the request",
"data": { },
"error": { }
}Failure
{
"success": false,
"message": "Something went wrong",
"data": { },
"error": {
"explanation": ["The incoming request does not contain a valid flightNumber."],
"statusCode": 400
}
}Errors are classified where the most context exists and then translated once:
| Origin | Handling |
|---|---|
| Missing or malformed request fields | Middleware returns 400 before the controller runs |
SequelizeValidationError / SequelizeUniqueConstraintError |
Service collects every err.message into an array and raises 400 |
| Record not found | CrudRepository raises 404; the service rewrites it with an entity-specific message |
| Anything else | Service raises 500 with a generic message so internals are not leaked |
| Status | Meaning |
|---|---|
200 OK |
Read, update, or delete succeeded |
201 Created |
Resource created |
400 Bad Request |
Validation failure, in error.explanation |
404 Not Found |
No record for the supplied id |
500 Internal Server Error |
Unexpected failure |
A naive read-modify-write lets two simultaneous bookings both read 5 remaining seats, both subtract 3, and both succeed — overselling the flight.
FlightRepository.updateRemainingSeats prevents this with a transaction plus a pessimistic row lock taken before the read:
SELECT * FROM flights WHERE flights.id = ? FOR UPDATEThe lock is held until the transaction commits or rolls back, so a second request for the same flight blocks instead of reading stale data, and any failure rolls back rather than leaving the seat count half-updated.
Winston is configured in src/config/logger-config.js with timestamped output to two transports:
- Console — for the local development loop.
- File (
combine.log) — for after-the-fact inspection. It is gitignored.
logger.info("Successfully started the server", "root", {});
