mirror of
https://github.com/logto-io/logto.git
synced 2025-04-07 23:01:25 -05:00
feat(core): add search and optional pagination to scopes (#2840)
This commit is contained in:
parent
67b1885b12
commit
0501ccc5ca
4 changed files with 87 additions and 7 deletions
|
@ -9,6 +9,7 @@ export type Pagination = {
|
|||
offset: number;
|
||||
limit: number;
|
||||
totalCount?: number;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type WithPaginationContext<ContextT> = ContextT & {
|
||||
|
@ -18,6 +19,7 @@ export type WithPaginationContext<ContextT> = ContextT & {
|
|||
export type PaginationConfig = {
|
||||
defaultPageSize?: number;
|
||||
maxPageSize?: number;
|
||||
isOptional?: boolean;
|
||||
};
|
||||
|
||||
export const isPaginationMiddleware = <Type extends IMiddleware>(
|
||||
|
@ -29,6 +31,7 @@ export const fallbackDefaultPageSize = 20;
|
|||
export default function koaPagination<StateT, ContextT, ResponseBodyT>({
|
||||
defaultPageSize = fallbackDefaultPageSize,
|
||||
maxPageSize = 100,
|
||||
isOptional = false,
|
||||
}: PaginationConfig = {}): MiddlewareType<StateT, WithPaginationContext<ContextT>, ResponseBodyT> {
|
||||
// Name this anonymous function for the utility function `isPaginationMiddleware` to identify it
|
||||
const paginationMiddleware: MiddlewareType<
|
||||
|
@ -42,19 +45,26 @@ export default function koaPagination<StateT, ContextT, ResponseBodyT>({
|
|||
query: { page, page_size },
|
||||
},
|
||||
} = ctx;
|
||||
// If isOptional is set to true, user can disable pagination by
|
||||
// set both `page` and `page_size` to empty
|
||||
const disabled = !page && !page_size && isOptional;
|
||||
// Query values are all string, need to convert to number first.
|
||||
const pageNumber = page ? number().positive().parse(Number(page)) : 1;
|
||||
const pageSize = page_size
|
||||
? number().positive().max(maxPageSize).parse(Number(page_size))
|
||||
: defaultPageSize;
|
||||
|
||||
ctx.pagination = { offset: (pageNumber - 1) * pageSize, limit: pageSize };
|
||||
ctx.pagination = { offset: (pageNumber - 1) * pageSize, limit: pageSize, disabled };
|
||||
} catch {
|
||||
throw new RequestError({ code: 'guard.invalid_pagination', status: 400 });
|
||||
}
|
||||
|
||||
await next();
|
||||
|
||||
if (ctx.pagination.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Total count value should be returned, else return internal server-error.
|
||||
if (ctx.pagination.totalCount === undefined) {
|
||||
throw new Error('missing totalCount');
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import type { CreateScope, Scope } from '@logto/schemas';
|
||||
import { Scopes } from '@logto/schemas';
|
||||
import type { OmitAutoSetFields } from '@logto/shared';
|
||||
import { convertToIdentifiers } from '@logto/shared';
|
||||
import { conditionalSql, convertToIdentifiers } from '@logto/shared';
|
||||
import { sql } from 'slonik';
|
||||
|
||||
import { buildFindEntityById } from '#src/database/find-entity-by-id.js';
|
||||
|
@ -9,6 +9,8 @@ import { buildInsertInto } from '#src/database/insert-into.js';
|
|||
import { buildUpdateWhere } from '#src/database/update-where.js';
|
||||
import envSet from '#src/env-set/index.js';
|
||||
import { DeletionError } from '#src/errors/SlonikError/index.js';
|
||||
import type { Search } from '#src/utils/search.js';
|
||||
import { buildConditionsFromSearch } from '#src/utils/search.js';
|
||||
|
||||
const { table, fields } = convertToIdentifiers(Scopes);
|
||||
|
||||
|
@ -19,6 +21,39 @@ export const findScopesByResourceId = async (resourceId: string) =>
|
|||
where ${fields.resourceId}=${resourceId}
|
||||
`);
|
||||
|
||||
const buildResourceConditions = (search: Search) => {
|
||||
const hasSearch = search.matches.length > 0;
|
||||
const searchFields = [Scopes.fields.id, Scopes.fields.name, Scopes.fields.description];
|
||||
|
||||
return conditionalSql(
|
||||
hasSearch,
|
||||
() => sql`and ${buildConditionsFromSearch(search, searchFields)}`
|
||||
);
|
||||
};
|
||||
|
||||
export const findScopes = async (
|
||||
resourceId: string,
|
||||
search: Search,
|
||||
limit?: number,
|
||||
offset?: number
|
||||
) =>
|
||||
envSet.pool.any<Scope>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`, `)}
|
||||
from ${table}
|
||||
where ${fields.resourceId}=${resourceId}
|
||||
${buildResourceConditions(search)}
|
||||
${conditionalSql(limit, (value) => sql`limit ${value}`)}
|
||||
${conditionalSql(offset, (value) => sql`offset ${value}`)}
|
||||
`);
|
||||
|
||||
export const countScopes = async (resourceId: string, search: Search) =>
|
||||
envSet.pool.one<{ count: number }>(sql`
|
||||
select count(*)
|
||||
from ${table}
|
||||
where ${fields.resourceId}=${resourceId}
|
||||
${buildResourceConditions(search)}
|
||||
`);
|
||||
|
||||
export const findScopesByResourceIds = async (resourceIds: string[]) =>
|
||||
resourceIds.length > 0
|
||||
? envSet.pool.any<Scope>(sql`
|
||||
|
|
|
@ -32,10 +32,10 @@ const { findResourceById } = mockEsm('#src/queries/resource.js', () => ({
|
|||
findScopesByResourceId: async () => [mockScope],
|
||||
}));
|
||||
|
||||
const { insertScope, updateScopeById } = mockEsm('#src/queries/scope.js', () => ({
|
||||
const { insertScope, updateScopeById } = await mockEsmWithActual('#src/queries/scope.js', () => ({
|
||||
findScopesByResourceId: async () => [mockScope],
|
||||
findScopes: async () => [mockScope],
|
||||
insertScope: jest.fn(async () => mockScope),
|
||||
findScopeById: jest.fn(),
|
||||
updateScopeById: jest.fn(async () => mockScope),
|
||||
deleteScopeById: jest.fn(),
|
||||
}));
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import { buildIdGenerator } from '@logto/core-kit';
|
||||
import { Resources, Scopes } from '@logto/schemas';
|
||||
import { tryThat } from '@logto/shared';
|
||||
import { object, string } from 'zod';
|
||||
|
||||
import { isTrue } from '#src/env-set/parameters.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { attachScopesToResources } from '#src/libraries/resource.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import koaPagination from '#src/middleware/koa-pagination.js';
|
||||
|
@ -15,11 +17,13 @@ import {
|
|||
deleteResourceById,
|
||||
} from '#src/queries/resource.js';
|
||||
import {
|
||||
countScopes,
|
||||
deleteScopeById,
|
||||
findScopesByResourceId,
|
||||
findScopes,
|
||||
insertScope,
|
||||
updateScopeById,
|
||||
} from '#src/queries/scope.js';
|
||||
import { parseSearchParamsForSearch } from '#src/utils/search.js';
|
||||
|
||||
import type { AuthedRouter } from './types.js';
|
||||
|
||||
|
@ -119,15 +123,46 @@ export default function resourceRoutes<T extends AuthedRouter>(router: T) {
|
|||
|
||||
router.get(
|
||||
'/resources/:resourceId/scopes',
|
||||
koaPagination({ isOptional: true }),
|
||||
koaGuard({ params: object({ resourceId: string().min(1) }) }),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { resourceId },
|
||||
} = ctx.guard;
|
||||
const { limit, offset, disabled } = ctx.pagination;
|
||||
const { searchParams } = ctx.request.URL;
|
||||
|
||||
ctx.body = await findScopesByResourceId(resourceId);
|
||||
return tryThat(
|
||||
async () => {
|
||||
const search = parseSearchParamsForSearch(searchParams);
|
||||
|
||||
return next();
|
||||
if (disabled) {
|
||||
ctx.body = await findScopes(resourceId, search);
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
const [{ count }, roles] = await Promise.all([
|
||||
countScopes(resourceId, search),
|
||||
findScopes(resourceId, search, limit, offset),
|
||||
]);
|
||||
|
||||
// Return totalCount to pagination middleware
|
||||
ctx.pagination.totalCount = count;
|
||||
ctx.body = roles;
|
||||
|
||||
return next();
|
||||
},
|
||||
(error) => {
|
||||
if (error instanceof TypeError) {
|
||||
throw new RequestError(
|
||||
{ code: 'request.invalid_input', details: error.message },
|
||||
error
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue