0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core): support pagination for GET /hooks api (#3904)

This commit is contained in:
Xiao Yijun 2023-05-29 11:43:08 +08:00 committed by GitHub
parent 79daf253a8
commit 51f61b455a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 103 additions and 34 deletions

View file

@ -0,0 +1,37 @@
import { type GeneratedSchema, type SchemaLike } from '@logto/schemas';
import {
type FieldIdentifiers,
conditionalSql,
convertToIdentifiers,
manyRows,
} from '@logto/shared';
import { sql, type CommonQueryMethods } from 'slonik';
export const buildFindAllEntitiesWithPool =
(pool: CommonQueryMethods) =>
<CreateSchema extends SchemaLike, Schema extends CreateSchema>(
schema: GeneratedSchema<CreateSchema, Schema>,
orderBy?: Array<{
field: keyof FieldIdentifiers<keyof GeneratedSchema<CreateSchema, Schema>['fields']>;
order: 'asc' | 'desc';
}>
) => {
const { table, fields } = convertToIdentifiers(schema);
return async (limit?: number, offset?: number) =>
manyRows(
pool.query<Schema>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
${conditionalSql(orderBy, (orderBy) => {
const orderBySql = orderBy.map(({ field, order }) =>
// Note: 'desc' and 'asc' are keywords, so we don't pass them as values
order === 'desc' ? sql`${fields[field]} desc` : sql`${fields[field]} asc`
);
return sql`order by ${sql.join(orderBySql, sql`, `)}`;
})}
${conditionalSql(limit, (limit) => sql`limit ${limit}`)}
${conditionalSql(offset, (offset) => sql`offset ${offset}`)}
`)
);
};

View file

@ -1,10 +1,11 @@
import type { Application, CreateApplication } from '@logto/schemas'; import type { CreateApplication } from '@logto/schemas';
import { Applications } from '@logto/schemas'; import { Applications } from '@logto/schemas';
import type { OmitAutoSetFields } from '@logto/shared'; import type { OmitAutoSetFields } from '@logto/shared';
import { convertToIdentifiers, conditionalSql, manyRows } from '@logto/shared'; import { convertToIdentifiers } from '@logto/shared';
import type { CommonQueryMethods } from 'slonik'; import type { CommonQueryMethods } from 'slonik';
import { sql } from 'slonik'; import { sql } from 'slonik';
import { buildFindAllEntitiesWithPool } from '#src/database/find-all-entities.js';
import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js'; import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js';
import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
import { getTotalRowCountWithPool } from '#src/database/row-count.js'; import { getTotalRowCountWithPool } from '#src/database/row-count.js';
@ -15,16 +16,9 @@ const { table, fields } = convertToIdentifiers(Applications);
export const createApplicationQueries = (pool: CommonQueryMethods) => { export const createApplicationQueries = (pool: CommonQueryMethods) => {
const findTotalNumberOfApplications = async () => getTotalRowCountWithPool(pool)(table); const findTotalNumberOfApplications = async () => getTotalRowCountWithPool(pool)(table);
const findAllApplications = async (limit: number, offset: number) => const findAllApplications = buildFindAllEntitiesWithPool(pool)(Applications, [
manyRows( { field: 'createdAt', order: 'desc' },
pool.query<Application>(sql` ]);
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
order by ${fields.createdAt} desc
${conditionalSql(limit, (limit) => sql`limit ${limit}`)}
${conditionalSql(offset, (offset) => sql`offset ${offset}`)}
`)
);
const findApplicationById = buildFindEntityByIdWithPool(pool)(Applications); const findApplicationById = buildFindEntityByIdWithPool(pool)(Applications);
const insertApplication = buildInsertIntoWithPool(pool)(Applications, { const insertApplication = buildInsertIntoWithPool(pool)(Applications, {
returning: true, returning: true,

View file

@ -1,25 +1,24 @@
import type { CreateHook, Hook } from '@logto/schemas'; import type { CreateHook } from '@logto/schemas';
import { Hooks } from '@logto/schemas'; import { Hooks } from '@logto/schemas';
import type { OmitAutoSetFields } from '@logto/shared'; import { type OmitAutoSetFields, convertToIdentifiers } from '@logto/shared';
import { convertToIdentifiers, manyRows } from '@logto/shared';
import type { CommonQueryMethods } from 'slonik'; import type { CommonQueryMethods } from 'slonik';
import { sql } from 'slonik'; import { sql } from 'slonik';
import { buildFindAllEntitiesWithPool } from '#src/database/find-all-entities.js';
import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js'; import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js';
import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
import { getTotalRowCountWithPool } from '#src/database/row-count.js';
import { buildUpdateWhereWithPool } from '#src/database/update-where.js'; import { buildUpdateWhereWithPool } from '#src/database/update-where.js';
import { DeletionError } from '#src/errors/SlonikError/index.js'; import { DeletionError } from '#src/errors/SlonikError/index.js';
const { table, fields } = convertToIdentifiers(Hooks); const { table, fields } = convertToIdentifiers(Hooks);
export const createHooksQueries = (pool: CommonQueryMethods) => { export const createHooksQueries = (pool: CommonQueryMethods) => {
const findAllHooks = async () => const getTotalNumberOfHooks = async () => getTotalRowCountWithPool(pool)(table);
manyRows(
pool.query<Hook>(sql` const findAllHooks = buildFindAllEntitiesWithPool(pool)(Hooks, [
select ${sql.join(Object.values(fields), sql`, `)} { field: 'createdAt', order: 'desc' },
from ${table} ]);
`)
);
const findHookById = buildFindEntityByIdWithPool(pool)(Hooks); const findHookById = buildFindEntityByIdWithPool(pool)(Hooks);
@ -47,6 +46,7 @@ export const createHooksQueries = (pool: CommonQueryMethods) => {
}; };
return { return {
getTotalNumberOfHooks,
findAllHooks, findAllHooks,
findHookById, findHookById,
insertHook, insertHook,

View file

@ -1,10 +1,11 @@
import type { Resource, CreateResource } from '@logto/schemas'; import type { Resource, CreateResource } from '@logto/schemas';
import { Resources } from '@logto/schemas'; import { Resources } from '@logto/schemas';
import type { OmitAutoSetFields } from '@logto/shared'; import type { OmitAutoSetFields } from '@logto/shared';
import { convertToIdentifiers, conditionalSql, manyRows } from '@logto/shared'; import { convertToIdentifiers } from '@logto/shared';
import type { CommonQueryMethods } from 'slonik'; import type { CommonQueryMethods } from 'slonik';
import { sql } from 'slonik'; import { sql } from 'slonik';
import { buildFindAllEntitiesWithPool } from '#src/database/find-all-entities.js';
import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js'; import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js';
import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
import { getTotalRowCountWithPool } from '#src/database/row-count.js'; import { getTotalRowCountWithPool } from '#src/database/row-count.js';
@ -16,15 +17,7 @@ const { table, fields } = convertToIdentifiers(Resources);
export const createResourceQueries = (pool: CommonQueryMethods) => { export const createResourceQueries = (pool: CommonQueryMethods) => {
const findTotalNumberOfResources = async () => getTotalRowCountWithPool(pool)(table); const findTotalNumberOfResources = async () => getTotalRowCountWithPool(pool)(table);
const findAllResources = async (limit?: number, offset?: number) => const findAllResources = buildFindAllEntitiesWithPool(pool)(Resources);
manyRows(
pool.query<Resource>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
${conditionalSql(limit, (limit) => sql`limit ${limit}`)}
${conditionalSql(offset, (offset) => sql`offset ${offset}`)}
`)
);
const findResourceByIndicator = async (indicator: string) => const findResourceByIndicator = async (indicator: string) =>
pool.maybeOne<Resource>(sql` pool.maybeOne<Resource>(sql`

View file

@ -23,6 +23,7 @@ import { createRequester } from '#src/utils/test-utils.js';
const { jest } = import.meta; const { jest } = import.meta;
const hooks = { const hooks = {
getTotalNumberOfHooks: async (): Promise<{ count: number }> => ({ count: mockHookList.length }),
findAllHooks: async (): Promise<Hook[]> => mockHookList, findAllHooks: async (): Promise<Hook[]> => mockHookList,
insertHook: async (data: CreateHook): Promise<Hook> => ({ insertHook: async (data: CreateHook): Promise<Hook> => ({
...mockHook, ...mockHook,
@ -104,6 +105,13 @@ describe('hook routes', () => {
expect(response.header).not.toHaveProperty('total-number'); expect(response.header).not.toHaveProperty('total-number');
}); });
it('GET /hooks?page=1&page_size=20', async () => {
const response = await hookRequest.get('/hooks?page=1&page_size=20');
expect(response.status).toEqual(200);
expect(response.body).toEqual(mockHookList);
expect(response.header).toHaveProperty('total-number');
});
it('GET /hooks?includeExecutionStats', async () => { it('GET /hooks?includeExecutionStats', async () => {
const response = await hookRequest.get('/hooks?includeExecutionStats=true'); const response = await hookRequest.get('/hooks?includeExecutionStats=true');
expect(attachExecutionStatsToHook).toHaveBeenCalledTimes(mockHookList.length); expect(attachExecutionStatsToHook).toHaveBeenCalledTimes(mockHookList.length);

View file

@ -19,7 +19,14 @@ export default function hookRoutes<T extends AuthedRouter>(
...[router, { queries, libraries }]: RouterInitArgs<T> ...[router, { queries, libraries }]: RouterInitArgs<T>
) { ) {
const { const {
hooks: { findAllHooks, findHookById, insertHook, updateHookById, deleteHookById }, hooks: {
getTotalNumberOfHooks,
findAllHooks,
findHookById,
insertHook,
updateHookById,
deleteHookById,
},
logs: { countLogs, findLogs }, logs: { countLogs, findLogs },
} = queries; } = queries;
@ -29,6 +36,7 @@ export default function hookRoutes<T extends AuthedRouter>(
router.get( router.get(
'/hooks', '/hooks',
koaPagination({ isOptional: true }),
koaGuard({ koaGuard({
query: z.object({ includeExecutionStats: z.string().optional() }), query: z.object({ includeExecutionStats: z.string().optional() }),
response: hookResponseGuard.partial({ executionStats: true }).array(), response: hookResponseGuard.partial({ executionStats: true }).array(),
@ -39,8 +47,24 @@ export default function hookRoutes<T extends AuthedRouter>(
query: { includeExecutionStats }, query: { includeExecutionStats },
} = ctx.guard; } = ctx.guard;
const hooks = await findAllHooks(); const { limit, offset, disabled: isPaginationDisabled } = ctx.pagination;
if (isPaginationDisabled) {
const hooks = await findAllHooks();
ctx.body = yes(includeExecutionStats)
? await Promise.all(hooks.map(async (hook) => attachExecutionStatsToHook(hook)))
: hooks;
return next();
}
const [{ count }, hooks] = await Promise.all([
getTotalNumberOfHooks(),
findAllHooks(limit, offset),
]);
ctx.pagination.totalCount = count;
ctx.body = yes(includeExecutionStats) ctx.body = yes(includeExecutionStats)
? await Promise.all(hooks.map(async (hook) => attachExecutionStatsToHook(hook))) ? await Promise.all(hooks.map(async (hook) => attachExecutionStatsToHook(hook)))
: hooks; : hooks;

View file

@ -55,6 +55,19 @@ describe('hooks', () => {
); );
}); });
it('should return hooks with pagination if pagination-related query params are provided', async () => {
const payload = getHookCreationPayload(HookEvent.PostRegister);
const created = await authedAdminApi.post('hooks', { json: payload }).json<Hook>();
const response = await authedAdminApi.get('hooks?page=1&page_size=20');
expect(response.statusCode).toBe(200);
expect(response.headers).toHaveProperty('total-number');
// Clean up
await authedAdminApi.delete(`hooks/${created.id}`);
});
it('should throw error when creating a hook with an empty hook name', async () => { it('should throw error when creating a hook with an empty hook name', async () => {
const payload = { const payload = {
name: '', name: '',