From 6d874d5250409a77b7c07530295db811e7a1a9a1 Mon Sep 17 00:00:00 2001 From: wangsijie Date: Thu, 12 Jan 2023 16:56:29 +0800 Subject: [PATCH] feat(core): add search and pagination to role scopes (#2925) --- packages/core/src/queries/scope.ts | 38 +++++- packages/core/src/routes/init.ts | 2 + packages/core/src/routes/resource.test.ts | 3 +- packages/core/src/routes/resource.ts | 18 +-- packages/core/src/routes/role.scope.test.ts | 95 ++++++++++++++ packages/core/src/routes/role.scope.ts | 132 ++++++++++++++++++++ packages/core/src/routes/role.test.ts | 40 +----- packages/core/src/routes/role.ts | 91 +------------- 8 files changed, 276 insertions(+), 143 deletions(-) create mode 100644 packages/core/src/routes/role.scope.test.ts create mode 100644 packages/core/src/routes/role.scope.ts diff --git a/packages/core/src/queries/scope.ts b/packages/core/src/queries/scope.ts index b1e76bb60..dd2969b5d 100644 --- a/packages/core/src/queries/scope.ts +++ b/packages/core/src/queries/scope.ts @@ -25,7 +25,12 @@ const buildResourceConditions = (search: Search) => { }; export const createScopeQueries = (pool: CommonQueryMethods) => { - const findScopes = async (resourceId: string, search: Search, limit?: number, offset?: number) => + const searchScopesByResourceId = async ( + resourceId: string, + search: Search, + limit?: number, + offset?: number + ) => pool.any(sql` select ${sql.join(Object.values(fields), sql`, `)} from ${table} @@ -35,7 +40,22 @@ export const createScopeQueries = (pool: CommonQueryMethods) => { ${conditionalSql(offset, (value) => sql`offset ${value}`)} `); - const countScopes = async (resourceId: string, search: Search) => + const searchScopesByScopeIds = async ( + scopeIds: string[], + search: Search, + limit?: number, + offset?: number + ) => + pool.any(sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where ${fields.id} in (${scopeIds.length > 0 ? sql.join(scopeIds, sql`, `) : sql`null`}) + ${buildResourceConditions(search)} + ${conditionalSql(limit, (value) => sql`limit ${value}`)} + ${conditionalSql(offset, (value) => sql`offset ${value}`)} + `); + + const countScopesByResourceId = async (resourceId: string, search: Search) => pool.one<{ count: number }>(sql` select count(*) from ${table} @@ -43,6 +63,14 @@ export const createScopeQueries = (pool: CommonQueryMethods) => { ${buildResourceConditions(search)} `); + const countScopesByScopeIds = async (scopeIds: string[], search: Search) => + pool.one<{ count: number }>(sql` + select count(*) + from ${table} + where ${fields.id} in (${scopeIds.length > 0 ? sql.join(scopeIds, sql`, `) : sql`null`}) + ${buildResourceConditions(search)} + `); + const findScopeByNameAndResourceId = async ( name: string, resourceId: string, @@ -116,8 +144,10 @@ export const createScopeQueries = (pool: CommonQueryMethods) => { }; return { - findScopes, - countScopes, + searchScopesByResourceId, + searchScopesByScopeIds, + countScopesByResourceId, + countScopesByScopeIds, findScopeByNameAndResourceId, findScopesByResourceId, findScopesByResourceIds, diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index a0d6c2a71..6e42896e5 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -18,6 +18,7 @@ import logRoutes from './log.js'; import phraseRoutes from './phrase.js'; import resourceRoutes from './resource.js'; import roleRoutes from './role.js'; +import roleScopeRoutes from './role.scope.js'; import settingRoutes from './setting.js'; import signInExperiencesRoutes from './sign-in-experience/index.js'; import statusRoutes from './status.js'; @@ -41,6 +42,7 @@ const createRouters = (tenant: TenantContext) => { adminUserRoleRoutes(managementRouter, tenant); logRoutes(managementRouter, tenant); roleRoutes(managementRouter, tenant); + roleScopeRoutes(managementRouter, tenant); dashboardRoutes(managementRouter, tenant); customPhraseRoutes(managementRouter, tenant); hookRoutes(managementRouter, tenant); diff --git a/packages/core/src/routes/resource.test.ts b/packages/core/src/routes/resource.test.ts index e4f0b6ae6..bca81044a 100644 --- a/packages/core/src/routes/resource.test.ts +++ b/packages/core/src/routes/resource.test.ts @@ -28,7 +28,8 @@ const { findResourceById } = resources; const scopes = { findScopesByResourceId: async () => [mockScope], - findScopes: async () => [mockScope], + searchScopesByResourceId: async () => [mockScope], + countScopesByResourceId: async () => ({ count: 1 }), insertScope: jest.fn(async () => mockScope), 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 4bc6dda86..033f6b8f4 100644 --- a/packages/core/src/routes/resource.ts +++ b/packages/core/src/routes/resource.ts @@ -28,9 +28,9 @@ export default function resourceRoutes( deleteResourceById, }, scopes: { - countScopes, + countScopesByResourceId, deleteScopeById, - findScopes, + searchScopesByResourceId, findScopeByNameAndResourceId, insertScope, updateScopeById, @@ -137,28 +137,22 @@ export default function resourceRoutes( router.get( '/resources/:resourceId/scopes', - koaPagination({ isOptional: true }), + koaPagination(), koaGuard({ params: object({ resourceId: string().min(1) }) }), async (ctx, next) => { const { params: { resourceId }, } = ctx.guard; - const { limit, offset, disabled } = ctx.pagination; + const { limit, offset } = ctx.pagination; const { searchParams } = ctx.request.URL; return tryThat( async () => { const search = parseSearchParamsForSearch(searchParams); - if (disabled) { - ctx.body = await findScopes(resourceId, search); - - return next(); - } - const [{ count }, roles] = await Promise.all([ - countScopes(resourceId, search), - findScopes(resourceId, search, limit, offset), + countScopesByResourceId(resourceId, search), + searchScopesByResourceId(resourceId, search, limit, offset), ]); // Return totalCount to pagination middleware diff --git a/packages/core/src/routes/role.scope.test.ts b/packages/core/src/routes/role.scope.test.ts new file mode 100644 index 000000000..d674573c4 --- /dev/null +++ b/packages/core/src/routes/role.scope.test.ts @@ -0,0 +1,95 @@ +import type { Role } from '@logto/schemas'; +import { pickDefault } from '@logto/shared/esm'; + +import { mockRole, mockScope, mockResource } from '#src/__mocks__/index.js'; +import { MockTenant } from '#src/test-utils/tenant.js'; +import { createRequester } from '#src/utils/test-utils.js'; + +const { jest } = import.meta; + +const roles = { + findRoles: jest.fn(async (): Promise => [mockRole]), + countRoles: jest.fn(async () => ({ count: 10 })), + insertRole: jest.fn(async (data) => ({ + ...data, + id: mockRole.id, + })), + findRoleById: jest.fn(), + updateRoleById: jest.fn(async (id, data) => ({ + ...mockRole, + ...data, + })), + findRolesByRoleIds: jest.fn(), +}; +const { findRoleById } = roles; + +const scopes = { + findScopeById: jest.fn(), + findScopesByIds: jest.fn(), + countScopesByScopeIds: jest.fn(async () => ({ count: 1 })), + searchScopesByScopeIds: jest.fn(async () => [mockScope]), +}; +const { findScopesByIds } = scopes; + +const resources = { + findResourcesByIds: jest.fn(async () => [mockResource]), +}; + +const rolesScopes = { + insertRolesScopes: jest.fn(), + findRolesScopesByRoleId: jest.fn(), + deleteRolesScope: jest.fn(), +}; +const { insertRolesScopes, findRolesScopesByRoleId } = rolesScopes; + +const users = { + findUserById: jest.fn(), +}; + +const roleRoutes = await pickDefault(import('./role.scope.js')); + +const tenantContext = new MockTenant(undefined, { + users, + rolesScopes, + resources, + scopes, + roles, +}); + +describe('role scope routes', () => { + const roleRequester = createRequester({ authedRoutes: roleRoutes, tenantContext }); + + it('GET /roles/:id/scopes', async () => { + findRoleById.mockResolvedValueOnce(mockRole); + findRolesScopesByRoleId.mockResolvedValueOnce([]); + findScopesByIds.mockResolvedValueOnce([mockScope]); + const response = await roleRequester.get(`/roles/${mockRole.id}/scopes`); + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + ...mockScope, + resource: mockResource, + }, + ]); + }); + + it('POST /roles/:id/scopes', async () => { + findRoleById.mockResolvedValueOnce(mockRole); + findRolesScopesByRoleId.mockResolvedValue([]); + findScopesByIds.mockResolvedValueOnce([]); + const response = await roleRequester.post(`/roles/${mockRole.id}/scopes`).send({ + scopeIds: [mockScope.id], + }); + expect(response.status).toEqual(200); + expect(insertRolesScopes).toHaveBeenCalledWith([ + { roleId: mockRole.id, scopeId: mockScope.id }, + ]); + }); + + it('DELETE /roles/:id/scopes/:scopeId', async () => { + findRoleById.mockResolvedValueOnce(mockRole); + findRolesScopesByRoleId.mockResolvedValueOnce([]); + const response = await roleRequester.delete(`/roles/${mockRole.id}/scopes/${mockScope.id}`); + expect(response.status).toEqual(204); + }); +}); diff --git a/packages/core/src/routes/role.scope.ts b/packages/core/src/routes/role.scope.ts new file mode 100644 index 000000000..e522df752 --- /dev/null +++ b/packages/core/src/routes/role.scope.ts @@ -0,0 +1,132 @@ +import type { ScopeResponse } from '@logto/schemas'; +import { tryThat } from '@logto/shared'; +import { object, string } from 'zod'; + +import RequestError from '#src/errors/RequestError/index.js'; +import koaGuard from '#src/middleware/koa-guard.js'; +import koaPagination from '#src/middleware/koa-pagination.js'; +import assertThat from '#src/utils/assert-that.js'; +import { parseSearchParamsForSearch } from '#src/utils/search.js'; + +import type { AuthedRouter, RouterInitArgs } from './types.js'; + +export default function roleScopeRoutes( + ...[router, { queries }]: RouterInitArgs +) { + const { + resources: { findResourcesByIds }, + rolesScopes: { deleteRolesScope, findRolesScopesByRoleId, insertRolesScopes }, + roles: { findRoleById }, + scopes: { findScopeById, findScopesByIds, countScopesByScopeIds, searchScopesByScopeIds }, + } = queries; + + router.get( + '/roles/:id/scopes', + koaPagination(), + koaGuard({ + params: object({ id: string().min(1) }), + }), + async (ctx, next) => { + const { + params: { id }, + } = ctx.guard; + const { limit, offset } = ctx.pagination; + const { searchParams } = ctx.request.URL; + await findRoleById(id); + + return tryThat( + async () => { + const search = parseSearchParamsForSearch(searchParams); + + const rolesScopes = await findRolesScopesByRoleId(id); + const scopeIds = rolesScopes.map(({ scopeId }) => scopeId); + + const [{ count }, scopes] = await Promise.all([ + countScopesByScopeIds(scopeIds, search), + searchScopesByScopeIds(scopeIds, search, limit, offset), + ]); + + const resources = await findResourcesByIds(scopes.map(({ resourceId }) => resourceId)); + const result: ScopeResponse[] = scopes.map((scope) => { + const resource = resources.find(({ id }) => scope.resourceId); + + assertThat(resource, new Error(`Cannot find resource for id ${scope.resourceId}`)); + + return { + ...scope, + resource, + }; + }); + + // Return totalCount to pagination middleware + ctx.pagination.totalCount = count; + ctx.body = result; + + return next(); + }, + (error) => { + if (error instanceof TypeError) { + throw new RequestError( + { code: 'request.invalid_input', details: error.message }, + error + ); + } + throw error; + } + ); + } + ); + + router.post( + '/roles/:id/scopes', + koaGuard({ + params: object({ id: string().min(1) }), + body: object({ scopeIds: string().min(1).array() }), + }), + async (ctx, next) => { + const { + params: { id }, + body: { scopeIds }, + } = ctx.guard; + + await findRoleById(id); + const rolesScopes = await findRolesScopesByRoleId(id); + + for (const scopeId of scopeIds) { + assertThat( + !rolesScopes.some(({ scopeId: _scopeId }) => _scopeId === scopeId), + new RequestError({ + code: 'role.scope_exists', + status: 422, + scopeId, + }) + ); + } + + await Promise.all(scopeIds.map(async (scopeId) => findScopeById(scopeId))); + await insertRolesScopes(scopeIds.map((scopeId) => ({ roleId: id, scopeId }))); + + const newRolesScopes = await findRolesScopesByRoleId(id); + const scopes = await findScopesByIds(newRolesScopes.map(({ scopeId }) => scopeId)); + ctx.body = scopes; + + return next(); + } + ); + + router.delete( + '/roles/:id/scopes/:scopeId', + koaGuard({ + params: object({ id: string().min(1), scopeId: string().min(1) }), + }), + async (ctx, next) => { + const { + params: { id, scopeId }, + } = ctx.guard; + await deleteRolesScope(id, scopeId); + ctx.status = 204; + + return next(); + } + ); +} diff --git a/packages/core/src/routes/role.test.ts b/packages/core/src/routes/role.test.ts index feb7fa72b..67ef9952d 100644 --- a/packages/core/src/routes/role.test.ts +++ b/packages/core/src/routes/role.test.ts @@ -28,9 +28,8 @@ const { findRoleByRoleName, findRoleById, deleteRoleById } = roles; const scopes = { findScopeById: jest.fn(), - findScopesByIds: jest.fn(), }; -const { findScopeById, findScopesByIds } = scopes; +const { findScopeById } = scopes; const resources = { findResourcesByIds: jest.fn(async () => [mockResource]), @@ -38,10 +37,9 @@ const resources = { const rolesScopes = { insertRolesScopes: jest.fn(), - findRolesScopesByRoleId: jest.fn(), deleteRolesScope: jest.fn(), }; -const { insertRolesScopes, findRolesScopesByRoleId } = rolesScopes; +const { insertRolesScopes } = rolesScopes; const users = { findUsersByIds: jest.fn(), @@ -156,40 +154,6 @@ describe('role routes', () => { expect(deleteRoleById).toHaveBeenCalledWith(mockRole.id); }); - it('GET /roles/:id/scopes', async () => { - findRoleById.mockResolvedValueOnce(mockRole); - findRolesScopesByRoleId.mockResolvedValueOnce([]); - findScopesByIds.mockResolvedValueOnce([mockScope]); - const response = await roleRequester.get(`/roles/${mockRole.id}/scopes`); - expect(response.status).toEqual(200); - expect(response.body).toEqual([ - { - ...mockScope, - resource: mockResource, - }, - ]); - }); - - it('POST /roles/:id/scopes', async () => { - findRoleById.mockResolvedValueOnce(mockRole); - findRolesScopesByRoleId.mockResolvedValue([]); - findScopesByIds.mockResolvedValueOnce([]); - const response = await roleRequester.post(`/roles/${mockRole.id}/scopes`).send({ - scopeIds: [mockScope.id], - }); - expect(response.status).toEqual(200); - expect(insertRolesScopes).toHaveBeenCalledWith([ - { roleId: mockRole.id, scopeId: mockScope.id }, - ]); - }); - - it('DELETE /roles/:id/scopes/:scopeId', async () => { - findRoleById.mockResolvedValueOnce(mockRole); - findRolesScopesByRoleId.mockResolvedValueOnce([]); - const response = await roleRequester.delete(`/roles/${mockRole.id}/scopes/${mockScope.id}`); - expect(response.status).toEqual(204); - }); - it('GET /roles/:id/users', async () => { findRoleById.mockResolvedValueOnce(mockRole); findUsersRolesByRoleId.mockResolvedValueOnce([]); diff --git a/packages/core/src/routes/role.ts b/packages/core/src/routes/role.ts index bb23c1416..1e0099ca0 100644 --- a/packages/core/src/routes/role.ts +++ b/packages/core/src/routes/role.ts @@ -1,5 +1,5 @@ import { buildIdGenerator } from '@logto/core-kit'; -import type { RoleResponse, ScopeResponse } from '@logto/schemas'; +import type { RoleResponse } from '@logto/schemas'; import { userInfoSelectFields, Roles } from '@logto/schemas'; import { tryThat } from '@logto/shared'; import { pick } from '@silverhand/essentials'; @@ -19,8 +19,7 @@ export default function roleRoutes( ...[router, { queries }]: RouterInitArgs ) { const { - resources: { findResourcesByIds }, - rolesScopes: { deleteRolesScope, findRolesScopesByRoleId, insertRolesScopes }, + rolesScopes: { insertRolesScopes }, roles: { countRoles, deleteRoleById, @@ -30,7 +29,7 @@ export default function roleRoutes( insertRole, updateRoleById, }, - scopes: { findScopeById, findScopesByIds }, + scopes: { findScopeById }, users: { findUserById, findUsersByIds }, usersRoles: { countUsersRolesByRoleId, @@ -189,90 +188,6 @@ export default function roleRoutes( } ); - router.get( - '/roles/:id/scopes', - koaGuard({ - params: object({ id: string().min(1) }), - }), - async (ctx, next) => { - const { - params: { id }, - } = ctx.guard; - - await findRoleById(id); - const rolesScopes = await findRolesScopesByRoleId(id); - const scopes = await findScopesByIds(rolesScopes.map(({ scopeId }) => scopeId)); - const resources = await findResourcesByIds(scopes.map(({ resourceId }) => resourceId)); - const result: ScopeResponse[] = scopes.map((scope) => { - const resource = resources.find(({ id }) => scope.resourceId); - - assertThat(resource, new Error(`Cannot find resource for id ${scope.resourceId}`)); - - return { - ...scope, - resource, - }; - }); - - ctx.body = result; - - return next(); - } - ); - - router.post( - '/roles/:id/scopes', - koaGuard({ - params: object({ id: string().min(1) }), - body: object({ scopeIds: string().min(1).array() }), - }), - async (ctx, next) => { - const { - params: { id }, - body: { scopeIds }, - } = ctx.guard; - - await findRoleById(id); - const rolesScopes = await findRolesScopesByRoleId(id); - - for (const scopeId of scopeIds) { - assertThat( - !rolesScopes.some(({ scopeId: _scopeId }) => _scopeId === scopeId), - new RequestError({ - code: 'role.scope_exists', - status: 422, - scopeId, - }) - ); - } - - await Promise.all(scopeIds.map(async (scopeId) => findScopeById(scopeId))); - await insertRolesScopes(scopeIds.map((scopeId) => ({ roleId: id, scopeId }))); - - const newRolesScopes = await findRolesScopesByRoleId(id); - const scopes = await findScopesByIds(newRolesScopes.map(({ scopeId }) => scopeId)); - ctx.body = scopes; - - return next(); - } - ); - - router.delete( - '/roles/:id/scopes/:scopeId', - koaGuard({ - params: object({ id: string().min(1), scopeId: string().min(1) }), - }), - async (ctx, next) => { - const { - params: { id, scopeId }, - } = ctx.guard; - await deleteRolesScope(id, scopeId); - ctx.status = 204; - - return next(); - } - ); - router.get( '/roles/:id/users', koaGuard({