diff --git a/packages/core/src/middleware/koa-pagination.ts b/packages/core/src/middleware/koa-pagination.ts index 6b0d01422..8036acaa1 100644 --- a/packages/core/src/middleware/koa-pagination.ts +++ b/packages/core/src/middleware/koa-pagination.ts @@ -9,6 +9,7 @@ export type Pagination = { offset: number; limit: number; totalCount?: number; + disabled?: boolean; }; export type WithPaginationContext = ContextT & { @@ -18,6 +19,7 @@ export type WithPaginationContext = ContextT & { export type PaginationConfig = { defaultPageSize?: number; maxPageSize?: number; + isOptional?: boolean; }; export const isPaginationMiddleware = ( @@ -29,6 +31,7 @@ export const fallbackDefaultPageSize = 20; export default function koaPagination({ defaultPageSize = fallbackDefaultPageSize, maxPageSize = 100, + isOptional = false, }: PaginationConfig = {}): MiddlewareType, ResponseBodyT> { // Name this anonymous function for the utility function `isPaginationMiddleware` to identify it const paginationMiddleware: MiddlewareType< @@ -42,19 +45,26 @@ export default function koaPagination({ 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'); diff --git a/packages/core/src/queries/scope.ts b/packages/core/src/queries/scope.ts index c57bf4174..0f257faa4 100644 --- a/packages/core/src/queries/scope.ts +++ b/packages/core/src/queries/scope.ts @@ -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(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(sql` diff --git a/packages/core/src/routes/resource.test.ts b/packages/core/src/routes/resource.test.ts index 367ca729a..be0c06dd0 100644 --- a/packages/core/src/routes/resource.test.ts +++ b/packages/core/src/routes/resource.test.ts @@ -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(), })); diff --git a/packages/core/src/routes/resource.ts b/packages/core/src/routes/resource.ts index 658b12782..523c3de94 100644 --- a/packages/core/src/routes/resource.ts +++ b/packages/core/src/routes/resource.ts @@ -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(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; + } + ); } );