diff --git a/Makefile b/Makefile index d48bf0db..cbf3eded 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ docker-run: docker run -it --rm --name $(DOCKER_IMAGE_NAME)-01 $(DOCKER_IMAGE_NAME) test: - npx tsx --test ${TEST_FILES} + DB_DRIVER=sqlite DB_FILENAME=":memory:" npx tsx --test ${TEST_FILES} lint: npx tsc --noemit diff --git a/src/principal/service.ts b/src/principal/service.ts index f93968b8..60fd6a9b 100644 --- a/src/principal/service.ts +++ b/src/principal/service.ts @@ -4,6 +4,7 @@ import { BasePrincipal, Group, NewPrincipal, + PaginatedResult, Principal, PrincipalIdentity, PrincipalStats, @@ -69,6 +70,37 @@ export class PrincipalService { } + async search(type: PrincipalType, page: number = 1): Promise> { + + this.privileges.require('a12n:principals:list'); + const filters: Record = {}; + filters.type = userTypeToInt(type); + + const pageSize = 100; + const offset = (page - 1) * pageSize; + + const total = (await getPrincipalStats()).user; + const hasNextPage = (offset + pageSize) < total; + + const result = await db('principals') + .where(filters) + .limit(pageSize) + .offset(offset); + + const items: T[] = []; + for (const principal of result) { + items.push(recordToModel(principal) as T); + } + + return { + items, + total, + page, + pageSize, + hasNextPage, + }; + } + async findByIdentity(identity: PrincipalIdentity|string): Promise { this.privileges.require('a12n:principals:list'); @@ -403,7 +435,6 @@ export async function getPrincipalStats(): Promise { } - function recordToModel(user: PrincipalsRecord): Principal { return { diff --git a/src/types.ts b/src/types.ts index 00ff2a0d..7674ad3d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -155,6 +155,17 @@ export type PrincipalStats = { group: number; }; +/** + * Paginated Result + */ +export type PaginatedResult = { + items: T[]; + total: number; + page: number; + pageSize: number; + hasNextPage: boolean; +} + /** * The App Client refers to a single set of credentials for an app. * diff --git a/src/user/controller/collection.ts b/src/user/controller/collection.ts index 9165485a..2cfba1e6 100644 --- a/src/user/controller/collection.ts +++ b/src/user/controller/collection.ts @@ -5,13 +5,19 @@ import * as hal from '../formats/hal.ts'; import * as services from '../../services.ts'; import { PrincipalNew } from '../../api-types.ts'; import { HalResource } from 'hal-types'; +import { User } from '../../../src/types.ts'; class UserCollectionController extends Controller { async get(ctx: Context) { const principalService = new services.principal.PrincipalService(ctx.privileges); - const users = await principalService.findAll('user'); + + const page = +ctx.request.query.page || 1; + + const paginatedResult = await principalService.search('user', page); + const users = paginatedResult.items; + const embed = ctx.request.prefer('transclude').toString().includes('item') || ctx.query.embed?.includes('item'); const embeddedUsers: HalResource[] = []; @@ -36,7 +42,7 @@ class UserCollectionController extends Controller { } } - ctx.response.body = hal.collection(users, embeddedUsers); + ctx.response.body = hal.collection(paginatedResult, embeddedUsers); } diff --git a/src/user/formats/hal.ts b/src/user/formats/hal.ts index 811b7656..915e8d2f 100644 --- a/src/user/formats/hal.ts +++ b/src/user/formats/hal.ts @@ -1,14 +1,18 @@ import { PrivilegeMap } from '../../privilege/types.ts'; -import { Principal, Group, User, PrincipalIdentity } from '../../types.ts'; -import { HalResource } from 'hal-types'; +import { Principal, Group, User, PrincipalIdentity, PaginatedResult } from '../../types.ts'; +import { HalLink, HalResource } from 'hal-types'; import { LazyPrivilegeBox } from '../../privilege/service.ts'; import { UserNewResult } from '../../api-types.ts'; -export function collection(users: User[], embeddedUsers: HalResource[]): HalResource { +export function collection(paginatedResult: PaginatedResult, embeddedUsers: HalResource[]): HalResource { + + const { items: users, page: currentPage, total, pageSize, hasNextPage } = paginatedResult; + + const totalPages = Math.ceil(total / pageSize); const hal: HalResource = { _links: { - 'self': { href: '/user' }, + 'self': getUserPageHref(currentPage), 'item': users.map( user => ({ href: user.href, title: user.nickname, @@ -20,9 +24,26 @@ export function collection(users: User[], embeddedUsers: HalResource[]): HalReso templated: true, }, }, - total: users.length, + total, + currentPage, + totalPages, }; + if(hasNextPage){ + const nextPage = currentPage + 1; + hal._links['next'] = getUserPageHref(nextPage); + } + + const hasPrevPage = currentPage > 1; + if(hasPrevPage){ + const prevPage = currentPage - 1; + hal._links['previous'] = getUserPageHref(prevPage); + } + if(hasNextPage || hasPrevPage){ + hal._links['first'] = getUserPageHref(1); + hal._links['last'] = getUserPageHref(totalPages); + } + if (embeddedUsers.length) { hal._embedded = { item: embeddedUsers @@ -33,6 +54,14 @@ export function collection(users: User[], embeddedUsers: HalResource[]): HalReso } +function getUserPageHref(page: number): HalLink { + if(page === 1){ + return { href: '/user' }; + } + + return { href: `/user?page=${page}` }; +} + /** * Generate a HAL response for a specific user * diff --git a/test/pagination/service.ts b/test/pagination/service.ts new file mode 100644 index 00000000..458e6aaf --- /dev/null +++ b/test/pagination/service.ts @@ -0,0 +1,155 @@ +import { strict as assert } from 'node:assert'; +import { after, before, describe, it } from 'node:test'; + +import { PrincipalService } from '../../src/principal/service.ts'; +import { PrincipalNew } from '../../src/api-types.ts'; +import * as hal from '../../src/user/formats/hal.ts'; +import db, { init } from '../../src/database.ts'; +import { User } from '../../src/types.ts'; +import { HalResource } from 'hal-types'; + +describe('users pagination', () => { + const principalService = new PrincipalService('insecure'); + let users: User[] = []; + + before(async () => { + await init(); + + const hasTable = await db.schema.hasTable('principals'); + if (!hasTable) { + await db.schema.createTable('principals', (table) => { + table.increments('id').primary(); + table.string('identity').nullable(); + table.string('external_id').notNullable(); + table.string('nickname').notNullable(); + table.integer('type').notNullable(); + table.bigInteger('created_at').defaultTo(db.fn.now()); + table.bigInteger('modified_at').defaultTo(db.fn.now()); + table.boolean('active').notNullable().defaultTo(false); + table.tinyint('system').notNullable().defaultTo(0); + }); + } + + // 3 pages worth of users + for(let i = 1; i < 251; i++){ + const data = { + type: 'user' as PrincipalNew['type'], + nickname: `User ${i}`, + createdAt: new Date(Date.now()), + modifiedAt: new Date(Date.now()), + active: true, + }; + await principalService.save(data); + } + + users = await principalService.findAll('user'); + }); + + after(async () => { + await db.destroy(); + }); + + describe('search service', () => { + it('should display first page', async () => { + const currentPage = 1; + const { items, pageSize, page, hasNextPage, total } = await principalService.search('user', currentPage); + const expectedUsers = users.slice(0, pageSize); + + assert.equal(page, currentPage); + assert.equal(hasNextPage, true); + assert.equal(total, users.length); + assert.deepEqual(items, expectedUsers); + }); + + it('should display second page', async () => { + const currentPage = 2; + const { items, pageSize, page, hasNextPage, total } = await principalService.search('user', currentPage); + const expectedUsers = users.slice(pageSize, 200); + + assert.equal(page, currentPage); + assert.equal(hasNextPage, true); + assert.equal(total, users.length); + assert.deepEqual(items, expectedUsers); + }); + + it('should display last (third) page', async () => { + const currentPage = 3; + const { items, page, hasNextPage, total } = await principalService.search('user', currentPage); + const expectedUsers = users.slice(200, users.length); + + assert.equal(page, currentPage); + assert.equal(hasNextPage, false); + assert.equal(total, users.length); + assert.deepEqual(items, expectedUsers); + }); + }); + + describe('hal.collection links', () => { + const embeddedUsers: HalResource[] = []; + + it('should not display `previous` link on first page', async () => { + const currentPage = 1; + + const paginatedResult = await principalService.search('user', currentPage); + const halRes = hal.collection(paginatedResult, embeddedUsers); + + assert.equal(halRes._links.previous, undefined); + assert.deepEqual(halRes._links.self, { + href: '/user', + }); + assert.deepEqual(halRes._links.first, { + href: '/user', + }); + assert.deepEqual(halRes._links.last, { + href: '/user?page=3', + }); + assert.deepEqual(halRes._links.next, { + href: '/user?page=2', + }); + }); + + it('should not display `next` link on last page', async () => { + const currentPage = 3; + + const paginatedResult = await principalService.search('user', currentPage); + const halRes = hal.collection(paginatedResult, embeddedUsers); + + assert.equal(halRes._links.next, undefined); + assert.deepEqual(halRes._links.self, { + href: '/user?page=3', + }); + assert.deepEqual(halRes._links.first, { + href: '/user', + }); + assert.deepEqual(halRes._links.last, { + href: '/user?page=3', + }); + assert.deepEqual(halRes._links.previous, { + href: '/user?page=2', + }); + }); + + it('should display both `previous` & `next` links on middle page', async () => { + const currentPage = 2; + + const paginatedResult = await principalService.search('user', currentPage); + const halRes = hal.collection(paginatedResult, embeddedUsers); + + assert.deepEqual(halRes._links.self, { + href: '/user?page=2', + }); + assert.deepEqual(halRes._links.first, { + href: '/user', + }); + assert.deepEqual(halRes._links.last, { + href: '/user?page=3', + }); + assert.deepEqual(halRes._links.previous, { + href: '/user', + }); + assert.deepEqual(halRes._links.next, { + href: '/user?page=3', + }); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 83794659..0423bb41 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,7 +23,7 @@ "DOM", "ES2022" ], - "declaration": true + "declaration": true, }, "include": [ "src/**/*"