apialize
Version:
Turn a database model into a production ready REST(ish) CRUD API in a few lines.
462 lines (396 loc) • 14.8 kB
JavaScript
const express = require('express');
const bodyParser = require('body-parser');
const request = require('supertest');
const { Sequelize, DataTypes } = require('sequelize');
const { list, search } = require('../src');
describe('included models filtering and ordering for list and search operations', () => {
let sequelize;
afterEach(async () => {
if (sequelize) {
await sequelize.close();
sequelize = null;
}
});
describe('basic included model filtering (dotted paths)', () => {
async function buildAppAndModels() {
sequelize = new Sequelize('sqlite::memory:', { logging: false });
const Artist = sequelize.define(
'Artist',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: { type: DataTypes.STRING(100), allowNull: false },
},
{ tableName: 'artists_included', timestamps: false }
);
const Album = sequelize.define(
'Album',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
title: { type: DataTypes.STRING(100), allowNull: false },
artist_id: { type: DataTypes.INTEGER, allowNull: false },
},
{ tableName: 'albums_included', timestamps: false }
);
Album.belongsTo(Artist, { as: 'artist', foreignKey: 'artist_id' });
Artist.hasMany(Album, { as: 'albums', foreignKey: 'artist_id' });
await sequelize.sync({ force: true });
const app = express();
app.use(bodyParser.json());
// Mount both list and search with include so $artist.name$ can be used via dotted filters
app.use(
'/albums',
list(Album, {}, { include: [{ model: Artist, as: 'artist' }] })
);
app.use(
'/albums',
search(Album, {}, { include: [{ model: Artist, as: 'artist' }] })
);
return { sequelize, Artist, Album, app };
}
async function seed(Artist, Album) {
const [prince, beethoven] = await Artist.bulkCreate(
[{ name: 'Prince' }, { name: 'Ludwig van Beethoven' }],
{ returning: true }
);
await Album.bulkCreate([
{ title: 'Purple Rain', artist_id: prince.id },
{ title: '1999', artist_id: prince.id },
{ title: 'Symphony No. 5', artist_id: beethoven.id },
]);
}
function titles(res) {
return res.body.data.map((r) => r.title);
}
test('list: filters by included association attribute using dotted path', async () => {
const ctx = await buildAppAndModels();
const { Artist, Album, app } = ctx;
await seed(Artist, Album);
// Default equality on string is case-insensitive: lower-case query matches 'Prince'
const res = await request(app).get(
'/albums?artist.name=prince&api:order_by=id'
);
expect(res.status).toBe(200);
expect(titles(res)).toEqual(['Purple Rain', '1999']);
// Explicit case-insensitive equality operator also works
const res2 = await request(app).get(
'/albums?artist.name:ieq=PRINCE&api:order_by=id'
);
expect(res2.status).toBe(200);
expect(titles(res2)).toEqual(['Purple Rain', '1999']);
});
test('search: filters by included association attribute using dotted path', async () => {
const ctx = await buildAppAndModels();
const { Artist, Album, app } = ctx;
await seed(Artist, Album);
// Equality match on included model attribute
const res1 = await request(app)
.post('/albums/search')
.send({ filtering: { 'artist.name': 'Ludwig van Beethoven' } });
expect(res1.status).toBe(200);
expect(titles(res1)).toEqual(['Symphony No. 5']);
// Case-insensitive contains on included attribute
const res2 = await request(app)
.post('/albums/search')
.send({ filtering: { 'artist.name': { icontains: 'prince' } } });
expect(res2.status).toBe(200);
expect(titles(res2)).toEqual(['Purple Rain', '1999']);
});
test('consistency: both operations return same results for same filter', async () => {
const ctx = await buildAppAndModels();
const { Artist, Album, app } = ctx;
await seed(Artist, Album);
// Test with list operation
const listRes = await request(app).get(
'/albums?artist.name=prince&api:order_by=id'
);
expect(listRes.status).toBe(200);
const listTitles = titles(listRes);
// Test with search operation (equivalent filter)
const searchRes = await request(app)
.post('/albums/search')
.send({
filtering: { 'artist.name': 'prince' },
ordering: { order_by: 'id' },
});
expect(searchRes.status).toBe(200);
const searchTitles = titles(searchRes);
// Both should return the same results
expect(listTitles).toEqual(searchTitles);
expect(listTitles).toEqual(['Purple Rain', '1999']);
});
});
describe('ordering by included attributes', () => {
async function buildAppAndModels() {
sequelize = new Sequelize('sqlite::memory:', { logging: false });
const Artist = sequelize.define(
'Artist',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: { type: DataTypes.STRING(100), allowNull: false },
},
{ tableName: 'artists_order', timestamps: false }
);
const Album = sequelize.define(
'Album',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
title: { type: DataTypes.STRING(100), allowNull: false },
artist_id: { type: DataTypes.INTEGER, allowNull: false },
},
{ tableName: 'albums_order', timestamps: false }
);
Album.belongsTo(Artist, { as: 'artist', foreignKey: 'artist_id' });
Artist.hasMany(Album, { as: 'albums', foreignKey: 'artist_id' });
await sequelize.sync({ force: true });
const app = express();
app.use(bodyParser.json());
app.use(
'/albums',
list(
Album,
{ metaShowOrdering: true },
{ include: [{ model: Artist, as: 'artist' }] }
)
);
app.use(
'/albums',
search(
Album,
{ metaShowOrdering: true },
{ include: [{ model: Artist, as: 'artist' }] }
)
);
return { sequelize, Artist, Album, app };
}
async function seed(Artist, Album) {
const [beethoven, prince] = await Artist.bulkCreate(
[{ name: 'Beethoven' }, { name: 'Prince' }],
{ returning: true }
);
await Album.bulkCreate([
{ title: 'Symphony No. 5', artist_id: beethoven.id },
{ title: '1999', artist_id: prince.id },
{ title: 'Purple Rain', artist_id: prince.id },
]);
}
function titles(res) {
return res.body.data.map((r) => r.title);
}
test('list: order by artist.name ASC then title ASC', async () => {
const ctx = await buildAppAndModels();
const { Artist, Album, app } = ctx;
await seed(Artist, Album);
const res = await request(app).get(
'/albums?api:order_by=artist.name,title'
);
expect(res.status).toBe(200);
// Beethoven first, then Prince (ordered by title within artist)
expect(titles(res)).toEqual(['Symphony No. 5', '1999', 'Purple Rain']);
expect(res.body.meta.order).toEqual([
['artist.name', 'ASC'],
['title', 'ASC'],
]);
});
test('search: order by artist.name DESC then title ASC via POST body', async () => {
const ctx = await buildAppAndModels();
const { Artist, Album, app } = ctx;
await seed(Artist, Album);
const res = await request(app)
.post('/albums/search')
.send({
ordering: [
{ order_by: 'artist.name', direction: 'DESC' },
{ order_by: 'title', direction: 'ASC' },
],
});
expect(res.status).toBe(200);
// Prince first (1999, Purple Rain), then Beethoven
expect(titles(res)).toEqual(['1999', 'Purple Rain', 'Symphony No. 5']);
expect(res.body.meta.order).toEqual([
['artist.name', 'DESC'],
['title', 'ASC'],
]);
});
test('consistency: both operations support ordering by included attributes', async () => {
const ctx = await buildAppAndModels();
const { Artist, Album, app } = ctx;
await seed(Artist, Album);
// Test with list operation (ASC order)
const listRes = await request(app).get(
'/albums?api:order_by=artist.name,title'
);
expect(listRes.status).toBe(200);
const listTitles = titles(listRes);
// Test with search operation (same ASC order)
const searchRes = await request(app)
.post('/albums/search')
.send({
ordering: [
{ order_by: 'artist.name', direction: 'ASC' },
{ order_by: 'title', direction: 'ASC' },
],
});
expect(searchRes.status).toBe(200);
const searchTitles = titles(searchRes);
// Both should return the same results
expect(listTitles).toEqual(searchTitles);
expect(listTitles).toEqual(['Symphony No. 5', '1999', 'Purple Rain']);
// Both should show the same ordering metadata
expect(listRes.body.meta.order).toEqual(searchRes.body.meta.order);
});
});
describe('multi-level include filtering and ordering', () => {
async function buildAppAndModels() {
sequelize = new Sequelize('sqlite::memory:', { logging: false });
const Label = sequelize.define(
'Label',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: { type: DataTypes.STRING(100), allowNull: false },
},
{ tableName: 'labels_multi', timestamps: false }
);
const Artist = sequelize.define(
'Artist',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: { type: DataTypes.STRING(100), allowNull: false },
label_id: { type: DataTypes.INTEGER, allowNull: false },
},
{ tableName: 'artists_multi', timestamps: false }
);
const Album = sequelize.define(
'Album',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
title: { type: DataTypes.STRING(100), allowNull: false },
artist_id: { type: DataTypes.INTEGER, allowNull: false },
},
{ tableName: 'albums_multi', timestamps: false }
);
Artist.belongsTo(Label, { as: 'label', foreignKey: 'label_id' });
Label.hasMany(Artist, { as: 'artists', foreignKey: 'label_id' });
Album.belongsTo(Artist, { as: 'artist', foreignKey: 'artist_id' });
Artist.hasMany(Album, { as: 'albums', foreignKey: 'artist_id' });
await sequelize.sync({ force: true });
const app = express();
app.use(bodyParser.json());
const includeConfig = {
include: [
{
model: Artist,
as: 'artist',
include: [{ model: Label, as: 'label' }],
},
],
};
app.use(
'/albums',
list(Album, { metaShowOrdering: true }, includeConfig)
);
app.use('/albums', search(Album, {}, includeConfig));
return { sequelize, Label, Artist, Album, app };
}
async function seed(Label, Artist, Album) {
const [warner, sony] = await Label.bulkCreate(
[{ name: 'Warner' }, { name: 'Sony' }],
{ returning: true }
);
const [prince, beethoven] = await Artist.bulkCreate(
[
{ name: 'Prince', label_id: warner.id },
{ name: 'Ludwig van Beethoven', label_id: sony.id },
],
{ returning: true }
);
await Album.bulkCreate([
{ title: '1999', artist_id: prince.id },
{ title: 'Symphony No. 5', artist_id: beethoven.id },
{ title: 'Purple Rain', artist_id: prince.id },
]);
}
function titles(res) {
return res.body.data.map((r) => r.title);
}
test('list: filter by artist.label.name and order by artist.label.name then artist.name', async () => {
const ctx = await buildAppAndModels();
const { Label, Artist, Album, app } = ctx;
await seed(Label, Artist, Album);
const res = await request(app).get(
'/albums?artist.label.name=warner&api:order_by=artist.label.name,artist.name'
);
expect(res.status).toBe(200);
// Only prince albums (label Warner), ordered by label then artist
expect(titles(res)).toEqual(['1999', 'Purple Rain']);
expect(res.body.meta.order).toEqual([
['artist.label.name', 'ASC'],
['artist.name', 'ASC'],
]);
});
test('search: filters by artist.label.name dotted path (case-insensitive equality by default)', async () => {
const ctx = await buildAppAndModels();
const { Label, Artist, Album, app } = ctx;
await seed(Label, Artist, Album);
// Default equality is case-insensitive
const res = await request(app)
.post('/albums/search')
.send({
filtering: { 'artist.label.name': 'warner' },
ordering: { order_by: 'id', direction: 'ASC' },
});
expect(res.status).toBe(200);
expect(titles(res)).toEqual(['1999', 'Purple Rain']);
});
test('consistency: both operations handle multi-level dotted paths', async () => {
const ctx = await buildAppAndModels();
const { Label, Artist, Album, app } = ctx;
await seed(Label, Artist, Album);
// Test with list operation
const listRes = await request(app).get(
'/albums?artist.label.name=warner&api:order_by=id'
);
expect(listRes.status).toBe(200);
const listTitles = titles(listRes);
// Test with search operation (equivalent filter)
const searchRes = await request(app)
.post('/albums/search')
.send({
filtering: { 'artist.label.name': 'warner' },
ordering: { order_by: 'id', direction: 'ASC' },
});
expect(searchRes.status).toBe(200);
const searchTitles = titles(searchRes);
// Both should return the same results
expect(listTitles).toEqual(searchTitles);
expect(listTitles).toEqual(['1999', 'Purple Rain']);
});
});
});