apialize
Version:
Turn a database model into a production ready REST(ish) CRUD API in a few lines.
228 lines (198 loc) • 7.23 kB
JavaScript
const express = require('express');
const bodyParser = require('body-parser');
const request = require('supertest');
const { Sequelize, DataTypes } = require('sequelize');
const { single, create, list } = require('../src');
async function build({
singleOptions = {},
modelOptions = {},
relatedConfig = null,
} = {}) {
const sequelize = new Sequelize('sqlite::memory:', { logging: false });
const User = sequelize.define(
'User',
{
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
external_id: {
type: DataTypes.STRING(64),
allowNull: false,
unique: true,
},
name: { type: DataTypes.STRING(100), allowNull: false },
parent_id: { type: DataTypes.INTEGER, allowNull: true },
},
{ tableName: 'single_users', timestamps: false }
);
const Post = sequelize.define(
'Post',
{
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
user_id: { type: DataTypes.INTEGER, allowNull: false },
title: { type: DataTypes.STRING(255), allowNull: false },
},
{ tableName: 'single_posts', timestamps: false }
);
// Attach relation-like info
User.hasMany(Post, { foreignKey: 'user_id', as: 'posts' });
Post.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
await sequelize.sync({ force: true });
const app = express();
app.use(bodyParser.json());
const options = { ...singleOptions };
if (relatedConfig)
options.related = [
relatedConfig === true
? { model: Post, operations: ['list', 'get'] }
: relatedConfig,
];
app.use('/users', create(User));
app.use('/posts', list(Post));
app.use('/users', single(User, options, modelOptions));
return { sequelize, User, Post, app };
}
describe('single operation: comprehensive options coverage', () => {
let sequelize;
afterEach(async () => {
if (sequelize) {
await sequelize.close();
sequelize = null;
}
});
test('default id mapping returns one record; respects modelOptions attributes', async () => {
const { sequelize: s, app } = await build({
modelOptions: { attributes: ['id', 'name'] },
});
sequelize = s;
const created = await request(app)
.post('/users')
.send({ external_id: 'u1', name: 'Alice' });
const res = await request(app).get(`/users/${created.body.id}`);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.record).toEqual({ id: created.body.id, name: 'Alice' });
});
test('id_mapping: external_id reads via external id', async () => {
const { sequelize: s, app } = await build({
singleOptions: { id_mapping: 'external_id' },
});
sequelize = s;
await request(app).post('/users').send({ external_id: 'ux', name: 'Bob' });
const res = await request(app).get(`/users/ux`);
expect(res.status).toBe(200);
expect(res.body.record).toMatchObject({ id: 'ux', name: 'Bob' });
expect(
Object.prototype.hasOwnProperty.call(res.body.record, 'external_id')
).toBe(false);
});
test('ownership filtering via query filters: 404 when not in scope', async () => {
const { sequelize: s, app } = await build();
sequelize = s;
const created = await request(app)
.post('/users')
.send({ external_id: 'scoped', name: 'Scoped', parent_id: 1 });
const miss = await request(app).get(
`/users/${created.body.id}?parent_id=2`
);
expect(miss.status).toBe(404);
const ok = await request(app).get(`/users/${created.body.id}?parent_id=1`);
expect(ok.status).toBe(200);
});
test('middleware modifies context before read (inject filter)', async () => {
const scope = (req, _res, next) => {
req.apialize.applyWhere({ parent_id: 5 });
next();
};
const { sequelize: s, app } = await build({
singleOptions: { middleware: [scope] },
});
sequelize = s;
const u1 = await request(app)
.post('/users')
.send({ external_id: 't5-1', name: 'A', parent_id: 5 });
await request(app)
.post('/users')
.send({ external_id: 't9-1', name: 'B', parent_id: 9 });
const ok = await request(app).get(`/users/${u1.body.id}`);
expect(ok.status).toBe(200);
const miss = await request(app).get(`/users/${u1.body.id}`);
expect(miss.status).toBe(200); // same scope still applied; same record
});
test('related recursion mounted via single() nested router works for get/list & write ops scoping', async () => {
const {
sequelize: s,
User,
Post: P2,
app,
} = await build({ relatedConfig: true });
sequelize = s;
const u = await request(app)
.post('/users')
.send({ external_id: 'usr1', name: 'U1' });
// Seed a post directly
await P2.create({ user_id: u.body.id, title: 'P1' });
// List child via related list
const listRes = await request(app).get(`/users/${u.body.id}/posts`);
expect(listRes.status).toBe(200);
expect(listRes.body.meta.count).toBe(1);
// GET child via nested single
const post = await P2.findOne({ where: { user_id: u.body.id } });
const getChild = await request(app).get(
`/users/${u.body.id}/posts/${post.id}`
);
expect(getChild.status).toBe(200);
expect(getChild.body.record).toMatchObject({ id: post.id, title: 'P1' });
});
test('array pre/post hooks: multiple functions execute in order (single)', async () => {
const executionOrder = [];
const {
sequelize: s,
User,
app,
} = await build({
singleOptions: {
pre: [
async (context) => {
executionOrder.push('pre1');
expect(context.transaction).toBeTruthy();
return { step: 1 };
},
async (context) => {
executionOrder.push('pre2');
expect(context.transaction).toBeTruthy();
return { step: 2 };
},
async (context) => {
executionOrder.push('pre3');
expect(context.transaction).toBeTruthy();
return { step: 3, finalPre: true };
},
],
post: [
async (context) => {
executionOrder.push('post1');
expect(context.preResult).toEqual({ step: 3, finalPre: true });
context.payload.hook1 = 'executed';
},
async (context) => {
executionOrder.push('post2');
expect(context.payload.hook1).toBe('executed');
context.payload.hook2 = 'also-executed';
},
],
},
});
sequelize = s;
// Create a user first
const created = await request(app)
.post('/users')
.send({ external_id: 'array-hooks-s1', name: 'ArraySingleTest' });
expect(created.status).toBe(201);
// Then retrieve it with array hooks
const retrieved = await request(app).get(`/users/${created.body.id}`);
expect(retrieved.status).toBe(200);
expect(retrieved.body.success).toBe(true);
expect(retrieved.body.hook1).toBe('executed');
expect(retrieved.body.hook2).toBe('also-executed');
expect(executionOrder).toEqual(['pre1', 'pre2', 'pre3', 'post1', 'post2']);
});
});