Skip to content

Commit a19a372

Browse files
committed
combining joi validation schemas and koa middlewares
1 parent 34e728b commit a19a372

22 files changed

+983
-15
lines changed

03-address-book-joi/.env

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# default env values. can be complemented by .env.development or .env.test
2+
# override values using .env.local, .env.development.local or .env.test.local
3+
PORT=3000
4+
ORIGINS=*
5+
PG_DATA=./pgdata

03-address-book-joi/.env.test

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# extra configs complementing default .env when running in test mode
2+
PORT=3001
3+
PG_DATA=memory://pgdata-testing

03-address-book-joi/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ npm i @electric-sql/pglite cross-env dotenv-flow signale joi
3535
npm i -D nodemon xo c8 ava supertest
3636
mkdir -p app/{configs/migrations,controllers,services}
3737
touch .env .env.development .env.test index.js app/{app.spec.js,server.js}
38-
touch app/configs/{cross-origin.js,database.js,security.js,logging.js}
38+
touch app/configs/{cross-origin.js,database.js,security.js,logging.js,validation.js}
3939
touch app/configs/{hook-test-context.js,no-rollback.js}
4040
touch app/configs/migrations/{2024-07-13-start-schema.sql,3000-test-data.sql}
4141
touch app/controllers/{controllers.spec.js,addresses.js,people.js}

03-address-book-joi/app/app.spec.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import test from 'ava';
2+
import request from 'supertest';
3+
import {testSetup, testTeardown} from './configs/hook-test-context.js';
4+
5+
test.before(testSetup);
6+
7+
test.after.always(testTeardown);
8+
9+
test('it should be ONLINE', async t => {
10+
const result = await request(t.context.app.callback()).get('/health/status');
11+
t.truthy(result);
12+
t.is(result.status, 200);
13+
t.is(result.text, 'ONLINE');
14+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Database.js
2+
import {PGlite} from '@electric-sql/pglite';
3+
import {noRollback} from './no-rollback.js';
4+
5+
/**
6+
* @type {PGlite} database - ref to connection singleton
7+
*/
8+
let connection;
9+
10+
/**
11+
* Provision a database connection with {PGLite}
12+
*
13+
* @param {*} config config options to prepare the database connection
14+
* @param {String} config.dataDir path or dsn to database
15+
*
16+
* @returns {Promise<PGlite>} valid database connection instance
17+
*/
18+
export const prepareDatabase = async config => {
19+
// Config fallback
20+
config ||= {dataDir: process.env.PG_DATA};
21+
// https://github.com/sombriks/pglite/tree/main?tab=readme-ov-file#limitations
22+
if (!connection || connection.closed) {
23+
connection = new PGlite(config.dataDir, {debug: 0});
24+
const {rows: [{result}]} = await connection.query('select 1 + 1 as result');
25+
if (result !== 2) {
26+
throw new Error('database issue');
27+
}
28+
29+
const migrate = await noRollback(connection);
30+
console.log(migrate);
31+
}
32+
33+
return connection;
34+
};
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {prepareAddressesServices} from '../services/addresses.js';
2+
import {prepareAddressesRequests} from '../controllers/addresses.js';
3+
import {preparePeopleServices} from '../services/people.js';
4+
import {preparePhonesServices} from '../services/phones.js';
5+
import {preparePeopleRequests} from '../controllers/people.js';
6+
import {prepareServer} from '../server.js';
7+
import {prepareDatabase} from './database.js';
8+
9+
export const testSetup = async t => {
10+
const database = await prepareDatabase();
11+
12+
const addressesServices = await prepareAddressesServices({db: database});
13+
const addressesRequests = await prepareAddressesRequests({addressesServices});
14+
15+
const peopleServices = await preparePeopleServices({db: database});
16+
const phonesServices = await preparePhonesServices({db: database});
17+
const peopleRequests = await preparePeopleRequests({peopleServices, phonesServices});
18+
19+
const app = await prepareServer({addressesRequests, peopleRequests});
20+
21+
t.context.addressesService = addressesServices;
22+
t.context.peopleServices = peopleServices;
23+
t.context.phonesServices = phonesServices;
24+
t.context.database = database;
25+
t.context.app = app;
26+
};
27+
28+
export const testTeardown = async t => {
29+
await t.context.database.close();
30+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import Signale from 'signale/signale.js';
2+
3+
/**
4+
* Logger to better track info
5+
*/
6+
export const logger = new Signale({
7+
config: {
8+
displayTimestamp: true,
9+
displayLabel: false,
10+
displayBadge: true,
11+
displayScope: true,
12+
displayDate: true,
13+
},
14+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
--
2+
-- base tables
3+
--
4+
-- addresses in the book
5+
create table addresses(
6+
id serial primary key,
7+
"description" text not null,
8+
"complement" text not null,
9+
created timestamp not null default now(),
10+
updated timestamp not null default now()
11+
);
12+
-- people, a person with zero or more addresses
13+
create table people(
14+
id serial primary key,
15+
"name" text not null,
16+
created timestamp not null default now(),
17+
updated timestamp not null default now()
18+
);
19+
-- N:N cardinality
20+
create table addresses_people(
21+
addresses_id integer not null,
22+
people_id integer not null,
23+
foreign key (addresses_id) references addresses(id) on delete cascade,
24+
foreign key (people_id) references people(id) on delete cascade,
25+
primary key (addresses_id, people_id)
26+
);
27+
-- people might have more than one phone number, but any number belongs to someone
28+
create table phones(
29+
id serial primary key,
30+
people_id integer not null,
31+
"number" text unique not null,
32+
-- no number belongs to two distinct people
33+
created timestamp not null default now(),
34+
updated timestamp not null default now(),
35+
foreign key (people_id) references people(id) on delete cascade
36+
);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
--
2+
-- test data so we have a known state in database to run tests
3+
--
4+
insert into addresses("description", complement)
5+
values ('Road 01 residence', 'Flower block'),
6+
('Dead end street', ''),
7+
('Horses alley, no number ', 'Burrow #2'),
8+
('Tomato avenue 110', ''),
9+
('Old boulevard 45', 'uphill cabin');
10+
insert into people("name")
11+
values ('Alice'),
12+
('Bob'),
13+
('Caesar'),
14+
('David'),
15+
('Edward');
16+
insert into addresses_people (addresses_id, people_id)
17+
values (1, 1),
18+
(1, 2),
19+
(2, 3),
20+
(3, 4),
21+
(4, 5);
22+
insert into phones(people_id, "number")
23+
values (1, '1234'),
24+
(2, '5678'),
25+
(5, '9101112');
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {readFileSync} from 'node:fs';
2+
import {logger} from './logging.js';
3+
4+
const log = logger.scope('no-rollback.js');
5+
6+
/**
7+
* Apply database migrations
8+
*
9+
* @param {import('@electric-sql/pglite')} database database connection instance
10+
*/
11+
export const noRollback = async database => {
12+
log.info('check database desired state/migrations...');
13+
const metadata = `
14+
-- store script names already executed
15+
create table if not exists no_rollback_from_here(
16+
created timestamp default now(),
17+
path text unique not null,
18+
primary key (created, path)
19+
);
20+
-- lock while applying migrates to avoid possible concurrent executions
21+
create table if not exists lock_no_rollback(
22+
locked integer not null default 1 check (locked = 1),
23+
created timestamp default now(),
24+
primary key (locked)
25+
);
26+
`;
27+
await database.exec(metadata);
28+
29+
// Lock table to avoid concurrent migrations
30+
try {
31+
await database.exec('insert into lock_no_rollback default values');
32+
} catch (error) {
33+
log.error(`
34+
=====
35+
failed to lock for migration.
36+
either something went wrong or there is a migration happening.
37+
bailing out.
38+
=====
39+
`);
40+
throw error;
41+
}
42+
43+
// Add new migrate files here, in this array.
44+
const files = ['app/configs/migrations/2024-07-13-start-schema.sql'];
45+
// Desired state for test suite
46+
if (process.env.NODE_ENV === 'test') {
47+
files.push('app/configs/migrations/3000-test-data.sql');
48+
}
49+
50+
const result = {executed: [], status: 'success', total: files.length};
51+
for await (const file of files) {
52+
await database.transaction(async tx => {
53+
const migrate = readFileSync(file, 'utf8');
54+
55+
const skip = await tx.query(`
56+
select created, path
57+
from no_rollback_from_here
58+
where path = $1
59+
`, [file]);
60+
if (skip.rows.length > 0) {
61+
const {created, path} = skip.rows[0];
62+
log.debug(`already executed: [${created.toISOString()}] [${path}]`);
63+
} else {
64+
log.debug(`prepare to execute [${file}]`);
65+
await tx.exec(migrate);
66+
log.debug('done!');
67+
await tx.query('insert into no_rollback_from_here (path) values ($1)', [file]);
68+
result.executed.push(file);
69+
}
70+
});
71+
}
72+
73+
await database.exec('delete from lock_no_rollback');
74+
log.info('done with database migration!');
75+
return result;
76+
};
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import Joi from 'joi';
2+
import { logger } from "./logging.js";
3+
4+
const log = logger.scope('validation.js')
5+
6+
const addressSchema = Joi.object({
7+
id: Joi.number(),
8+
description: Joi.string(),
9+
complement: Joi.string(),
10+
created: Joi.date()
11+
})
12+
13+
/**
14+
* middleware to validate address payload with joi schema
15+
*
16+
* @param {*} context
17+
* @param {*} next
18+
*/
19+
export const ifValidAddress = async (context, next) => {
20+
log.info('validating address...')
21+
const { error } = addressSchema.validate(context.request.body)
22+
if (error) {
23+
log.warn(error);
24+
return context.throw(400, error);
25+
}
26+
return await next()
27+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { prepareAddressesServices } from '../services/addresses.js';
2+
import { logger } from '../configs/logging.js';
3+
4+
// Define a scope so you can better track from where the log message came from
5+
const log = logger.scope('controllers', 'addresses.js');
6+
7+
export const prepareAddressesRequests = async options => {
8+
options ||= { addressesServices: await prepareAddressesServices() };
9+
const { addressesServices } = options;
10+
return {
11+
async list(context) {
12+
log.info('list addresses');
13+
const { q = '' } = context.request.query;
14+
context.body = await addressesServices.list({ q });
15+
},
16+
async find(context) {
17+
log.info('find address');
18+
const result = await addressesServices.find(context.request.params);
19+
if (result) {
20+
context.body = result;
21+
} else {
22+
log.warn('No address found');
23+
context.throw(404, 'Not Found');
24+
}
25+
},
26+
async create(context) {
27+
log.info('create address');
28+
const { description, complement } = context.request.body;
29+
const id = await addressesServices.create({ address: { description, complement } });
30+
context.status = 201;
31+
context.set('Location', `/addresses/${id}`); // Politely guide clients to somewhere else
32+
context.body = { message: `#${id} created` };
33+
},
34+
async update(context) {
35+
log.info('update address');
36+
const { id } = context.params;
37+
const { body: address } = context.request;
38+
const affected = await addressesServices.update({ id, address });
39+
if (!affected) {
40+
log.warn('No address updated');
41+
}
42+
context.status = 303; // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303
43+
context.set('Location', `/addresses/${id}`);
44+
context.body = { message: `${affected} updated` };
45+
},
46+
async del(context) {
47+
log.info('delete address');
48+
const affected = await addressesServices.del(context.request.params);
49+
if (!affected) {
50+
log.warn('No address removed');
51+
}
52+
context.status = 303;
53+
context.set('Location', '/addresses');
54+
context.body = { message: `${affected} deleted` };
55+
},
56+
people: {
57+
async list(context) {
58+
log.info('list people from address');
59+
const { id: addresses_id } = context.params;
60+
context.body = await addressesServices.people.list({ addresses_id });
61+
},
62+
async add(context) {
63+
log.info('add people into address');
64+
const { id: addresses_id, people_id } = context.params;
65+
const affected = await addressesServices.people.add({ addresses_id, people_id });
66+
if (!affected) {
67+
log.warn('No people added into address #' + addresses_id);
68+
}
69+
context.status = 303;
70+
context.set('Location', `/addresses/${addresses_id}/people`);
71+
context.body = { message: `${affected} added` };
72+
},
73+
async del(context) {
74+
log.info('remove people from address');
75+
const { id: addresses_id, people_id } = context.params;
76+
const affected = await addressesServices.people.del({ addresses_id, people_id });
77+
if (!affected) {
78+
log.warn('No people removed from address #' + addresses_id);
79+
}
80+
context.status = 303;
81+
context.set('Location', `/addresses/${addresses_id}/people`);
82+
context.body = { message: `${affected} removed` };
83+
},
84+
},
85+
};
86+
};

0 commit comments

Comments
 (0)