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 { 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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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`
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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: '',
|
||||||
|
|
Loading…
Reference in a new issue