0
Fork 0
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:
wangsijie 2023-01-08 20:24:16 +08:00 committed by GitHub
parent 67b1885b12
commit 0501ccc5ca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 87 additions and 7 deletions

View file

@ -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');

View file

@ -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`

View file

@ -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(),
}));

View file

@ -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;
}
);
}
);