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:
parent
79daf253a8
commit
51f61b455a
7 changed files with 103 additions and 34 deletions
37
packages/core/src/database/find-all-entities.ts
Normal file
37
packages/core/src/database/find-all-entities.ts
Normal 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}`)}
|
||||
`)
|
||||
);
|
||||
};
|
|
@ -1,10 +1,11 @@
|
|||
import type { Application, CreateApplication } from '@logto/schemas';
|
||||
import type { CreateApplication } from '@logto/schemas';
|
||||
import { Applications } from '@logto/schemas';
|
||||
import type { OmitAutoSetFields } from '@logto/shared';
|
||||
import { convertToIdentifiers, conditionalSql, manyRows } from '@logto/shared';
|
||||
import { convertToIdentifiers } from '@logto/shared';
|
||||
import type { CommonQueryMethods } 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 { buildInsertIntoWithPool } from '#src/database/insert-into.js';
|
||||
import { getTotalRowCountWithPool } from '#src/database/row-count.js';
|
||||
|
@ -15,16 +16,9 @@ const { table, fields } = convertToIdentifiers(Applications);
|
|||
|
||||
export const createApplicationQueries = (pool: CommonQueryMethods) => {
|
||||
const findTotalNumberOfApplications = async () => getTotalRowCountWithPool(pool)(table);
|
||||
const findAllApplications = async (limit: number, offset: number) =>
|
||||
manyRows(
|
||||
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 findAllApplications = buildFindAllEntitiesWithPool(pool)(Applications, [
|
||||
{ field: 'createdAt', order: 'desc' },
|
||||
]);
|
||||
const findApplicationById = buildFindEntityByIdWithPool(pool)(Applications);
|
||||
const insertApplication = buildInsertIntoWithPool(pool)(Applications, {
|
||||
returning: true,
|
||||
|
|
|
@ -1,25 +1,24 @@
|
|||
import type { CreateHook, Hook } from '@logto/schemas';
|
||||
import type { CreateHook } from '@logto/schemas';
|
||||
import { Hooks } from '@logto/schemas';
|
||||
import type { OmitAutoSetFields } from '@logto/shared';
|
||||
import { convertToIdentifiers, manyRows } from '@logto/shared';
|
||||
import { type OmitAutoSetFields, convertToIdentifiers } from '@logto/shared';
|
||||
import type { CommonQueryMethods } 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 { buildInsertIntoWithPool } from '#src/database/insert-into.js';
|
||||
import { getTotalRowCountWithPool } from '#src/database/row-count.js';
|
||||
import { buildUpdateWhereWithPool } from '#src/database/update-where.js';
|
||||
import { DeletionError } from '#src/errors/SlonikError/index.js';
|
||||
|
||||
const { table, fields } = convertToIdentifiers(Hooks);
|
||||
|
||||
export const createHooksQueries = (pool: CommonQueryMethods) => {
|
||||
const findAllHooks = async () =>
|
||||
manyRows(
|
||||
pool.query<Hook>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`, `)}
|
||||
from ${table}
|
||||
`)
|
||||
);
|
||||
const getTotalNumberOfHooks = async () => getTotalRowCountWithPool(pool)(table);
|
||||
|
||||
const findAllHooks = buildFindAllEntitiesWithPool(pool)(Hooks, [
|
||||
{ field: 'createdAt', order: 'desc' },
|
||||
]);
|
||||
|
||||
const findHookById = buildFindEntityByIdWithPool(pool)(Hooks);
|
||||
|
||||
|
@ -47,6 +46,7 @@ export const createHooksQueries = (pool: CommonQueryMethods) => {
|
|||
};
|
||||
|
||||
return {
|
||||
getTotalNumberOfHooks,
|
||||
findAllHooks,
|
||||
findHookById,
|
||||
insertHook,
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import type { Resource, CreateResource } from '@logto/schemas';
|
||||
import { Resources } from '@logto/schemas';
|
||||
import type { OmitAutoSetFields } from '@logto/shared';
|
||||
import { convertToIdentifiers, conditionalSql, manyRows } from '@logto/shared';
|
||||
import { convertToIdentifiers } from '@logto/shared';
|
||||
import type { CommonQueryMethods } 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 { buildInsertIntoWithPool } from '#src/database/insert-into.js';
|
||||
import { getTotalRowCountWithPool } from '#src/database/row-count.js';
|
||||
|
@ -16,15 +17,7 @@ const { table, fields } = convertToIdentifiers(Resources);
|
|||
export const createResourceQueries = (pool: CommonQueryMethods) => {
|
||||
const findTotalNumberOfResources = async () => getTotalRowCountWithPool(pool)(table);
|
||||
|
||||
const findAllResources = async (limit?: number, offset?: number) =>
|
||||
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 findAllResources = buildFindAllEntitiesWithPool(pool)(Resources);
|
||||
|
||||
const findResourceByIndicator = async (indicator: string) =>
|
||||
pool.maybeOne<Resource>(sql`
|
||||
|
|
|
@ -23,6 +23,7 @@ import { createRequester } from '#src/utils/test-utils.js';
|
|||
const { jest } = import.meta;
|
||||
|
||||
const hooks = {
|
||||
getTotalNumberOfHooks: async (): Promise<{ count: number }> => ({ count: mockHookList.length }),
|
||||
findAllHooks: async (): Promise<Hook[]> => mockHookList,
|
||||
insertHook: async (data: CreateHook): Promise<Hook> => ({
|
||||
...mockHook,
|
||||
|
@ -104,6 +105,13 @@ describe('hook routes', () => {
|
|||
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 () => {
|
||||
const response = await hookRequest.get('/hooks?includeExecutionStats=true');
|
||||
expect(attachExecutionStatsToHook).toHaveBeenCalledTimes(mockHookList.length);
|
||||
|
|
|
@ -19,7 +19,14 @@ export default function hookRoutes<T extends AuthedRouter>(
|
|||
...[router, { queries, libraries }]: RouterInitArgs<T>
|
||||
) {
|
||||
const {
|
||||
hooks: { findAllHooks, findHookById, insertHook, updateHookById, deleteHookById },
|
||||
hooks: {
|
||||
getTotalNumberOfHooks,
|
||||
findAllHooks,
|
||||
findHookById,
|
||||
insertHook,
|
||||
updateHookById,
|
||||
deleteHookById,
|
||||
},
|
||||
logs: { countLogs, findLogs },
|
||||
} = queries;
|
||||
|
||||
|
@ -29,6 +36,7 @@ export default function hookRoutes<T extends AuthedRouter>(
|
|||
|
||||
router.get(
|
||||
'/hooks',
|
||||
koaPagination({ isOptional: true }),
|
||||
koaGuard({
|
||||
query: z.object({ includeExecutionStats: z.string().optional() }),
|
||||
response: hookResponseGuard.partial({ executionStats: true }).array(),
|
||||
|
@ -39,8 +47,24 @@ export default function hookRoutes<T extends AuthedRouter>(
|
|||
query: { includeExecutionStats },
|
||||
} = 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)
|
||||
? await Promise.all(hooks.map(async (hook) => attachExecutionStatsToHook(hook)))
|
||||
: hooks;
|
||||
|
|
|
@ -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 () => {
|
||||
const payload = {
|
||||
name: '',
|
||||
|
|
Loading…
Reference in a new issue