0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -05:00

Merge pull request #4663 from logto-io/gao-reorg-routes

refactor(core): reorg routes
This commit is contained in:
Gao Sun 2023-10-16 03:04:50 -05:00 committed by GitHub
commit 1a9c72ce66
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 141 additions and 91 deletions

View file

@ -3,7 +3,7 @@ import { sql } from 'slonik';
export const buildGetTotalRowCountWithPool =
(pool: CommonQueryMethods, table: string) => async () => {
// Postgres returns a biging for count(*), which is then converted to a string by query library.
// Postgres returns a bigint for count(*), which is then converted to a string by query library.
// We need to convert it to a number.
const { count } = await pool.one<{ count: string }>(sql`
select count(*)
@ -15,7 +15,7 @@ export const buildGetTotalRowCountWithPool =
export const getTotalRowCountWithPool =
(pool: CommonQueryMethods) => async (table: IdentifierSqlToken) => {
// Postgres returns a biging for count(*), which is then converted to a string by query library.
// Postgres returns a bigint for count(*), which is then converted to a string by query library.
// We need to convert it to a number.
const { count } = await pool.one<{ count: string }>(sql`
select count(*)

View file

@ -95,7 +95,7 @@ const usersLibraries = {
),
} satisfies Partial<Libraries['users']>;
const adminUserRoutes = await pickDefault(import('./admin-user.js'));
const adminUserRoutes = await pickDefault(import('./basics.js'));
describe('adminUserRoutes', () => {
const tenantContext = new MockTenant(undefined, mockedQueries, undefined, {

View file

@ -14,11 +14,10 @@ import koaGuard from '#src/middleware/koa-guard.js';
import assertThat from '#src/utils/assert-that.js';
import { transpileUserMfaVerifications } from '#src/utils/user.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
import type { AuthedRouter, RouterInitArgs } from '../types.js';
export default function adminUserRoutes<T extends AuthedRouter>(
...[router, { queries, libraries }]: RouterInitArgs<T>
) {
export default function adminUserBasicsRoutes<T extends AuthedRouter>(...args: RouterInitArgs<T>) {
const [router, { queries, libraries }] = args;
const {
oidcModelInstances: { revokeInstanceByUserId },
users: {

View file

@ -0,0 +1,13 @@
import type { AuthedRouter, RouterInitArgs } from '../types.js';
import adminUserBasicsRoutes from './basics.js';
import adminUserRoleRoutes from './role.js';
import adminUserSearchRoutes from './search.js';
import adminUserSocialRoutes from './social.js';
export default function adminUserRoutes<T extends AuthedRouter>(...args: RouterInitArgs<T>) {
adminUserBasicsRoutes(...args);
adminUserRoleRoutes(...args);
adminUserSearchRoutes(...args);
adminUserSocialRoutes(...args);
}

View file

@ -35,7 +35,7 @@ const { findRolesByRoleIds } = roles;
const tenantContext = new MockTenant(undefined, { usersRoles, users, roles });
const roleRoutes = await pickDefault(import('./admin-user-role.js'));
const roleRoutes = await pickDefault(import('./role.js'));
describe('user role routes', () => {
const roleRequester = createRequester({ authedRoutes: roleRoutes, tenantContext });

View file

@ -10,7 +10,7 @@ import koaRoleRlsErrorHandler from '#src/middleware/koa-role-rls-error-handler.j
import assertThat from '#src/utils/assert-that.js';
import { parseSearchParamsForSearch } from '#src/utils/search.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
import type { AuthedRouter, RouterInitArgs } from '../types.js';
export default function adminUserRoleRoutes<T extends AuthedRouter>(
...[router, { queries }]: RouterInitArgs<T>

View file

@ -60,7 +60,7 @@ const usersLibraries = {
),
} satisfies Partial<Libraries['users']>;
const adminUserRoutes = await pickDefault(import('./admin-user-search.js'));
const adminUserRoutes = await pickDefault(import('./search.js'));
describe('adminUserRoutes', () => {
const tenantContext = new MockTenant(undefined, mockedQueries, undefined, {

View file

@ -6,7 +6,7 @@ import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import { parseSearchParamsForSearch } from '#src/utils/search.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
import type { AuthedRouter, RouterInitArgs } from '../types.js';
export default function adminUserSearchRoutes<T extends AuthedRouter>(
...[router, { queries }]: RouterInitArgs<T>

View file

@ -70,7 +70,7 @@ const mockedConnectors = {
const { findUserById, updateUserById, deleteUserIdentity } = mockedQueries.users;
const adminUserSocialRoutes = await pickDefault(import('./admin-user-social.js'));
const adminUserSocialRoutes = await pickDefault(import('./social.js'));
describe('Admin user social identities APIs', () => {
const tenantContext = new MockTenant(undefined, mockedQueries, mockedConnectors, {

View file

@ -12,7 +12,7 @@ import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import assertThat from '#src/utils/assert-that.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
import type { AuthedRouter, RouterInitArgs } from '../types.js';
export default function adminUserSocialRoutes<T extends AuthedRouter>(
...[router, tenant]: RouterInitArgs<T>

View file

@ -10,10 +10,7 @@ import type TenantContext from '#src/tenants/TenantContext.js';
import koaAuth from '../middleware/koa-auth/index.js';
import adminUserRoleRoutes from './admin-user-role.js';
import adminUserSearchRoutes from './admin-user-search.js';
import adminUserSocialRoutes from './admin-user-social.js';
import adminUserRoutes from './admin-user.js';
import adminUserRoutes from './admin-user/index.js';
import applicationRoleRoutes from './application-role.js';
import applicationRoutes from './application.js';
import authnRoutes from './authn.js';
@ -25,9 +22,7 @@ import hookRoutes from './hook.js';
import interactionRoutes from './interaction/index.js';
import logRoutes from './log.js';
import logtoConfigRoutes from './logto-config.js';
import organizationRoleRoutes from './organization-roles.js';
import organizationScopeRoutes from './organization-scopes.js';
import organizationRoutes from './organizations.js';
import organizationRoutes from './organization/index.js';
import resourceRoutes from './resource.js';
import roleRoutes from './role.js';
import roleScopeRoutes from './role.scope.js';
@ -54,9 +49,6 @@ const createRouters = (tenant: TenantContext) => {
resourceRoutes(managementRouter, tenant);
signInExperiencesRoutes(managementRouter, tenant);
adminUserRoutes(managementRouter, tenant);
adminUserSearchRoutes(managementRouter, tenant);
adminUserRoleRoutes(managementRouter, tenant);
adminUserSocialRoutes(managementRouter, tenant);
logRoutes(managementRouter, tenant);
roleRoutes(managementRouter, tenant);
roleScopeRoutes(managementRouter, tenant);
@ -67,8 +59,6 @@ const createRouters = (tenant: TenantContext) => {
userAssetsRoutes(managementRouter, tenant);
domainRoutes(managementRouter, tenant);
organizationRoutes(managementRouter, tenant);
organizationScopeRoutes(managementRouter, tenant);
organizationRoleRoutes(managementRouter, tenant);
const anonymousRouter: AnonymousRouter = new Router();
wellKnownRoutes(anonymousRouter, tenant);

View file

@ -3,18 +3,21 @@ import { z } 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 SchemaRouter, { SchemaActions } from '#src/utils/SchemaRouter.js';
import { type AuthedRouter, type RouterInitArgs } from './types.js';
import { type AuthedRouter, type RouterInitArgs } from '../types.js';
export default function organizationRoutes<T extends AuthedRouter>(
...[
import organizationRoleRoutes from './roles.js';
import organizationScopeRoutes from './scopes.js';
export default function organizationRoutes<T extends AuthedRouter>(...args: RouterInitArgs<T>) {
const [
originalRouter,
{
queries: { organizations, users },
},
]: RouterInitArgs<T>
) {
] = args;
const router = new SchemaRouter(Organizations, new SchemaActions(organizations));
router.addRelationRoutes(organizations.relations.users);
@ -26,22 +29,29 @@ export default function organizationRoutes<T extends AuthedRouter>(
router.get(
pathname,
koaPagination(),
koaGuard({
params: z.object(params),
response: OrganizationRoles.guard.array(),
status: [200, 404],
}),
// TODO: Add pagination
async (ctx, next) => {
const { id, userId } = ctx.guard.params;
// Ensure both the organization and the role exist
await Promise.all([organizations.findById(id), users.findUserById(userId)]);
ctx.body = await organizations.relations.rolesUsers.getEntries(OrganizationRoles, {
organizationId: id,
userId,
});
const [totalCount, entities] = await organizations.relations.rolesUsers.getEntities(
OrganizationRoles,
{
organizationId: id,
userId,
},
ctx.pagination
);
ctx.pagination.totalCount = totalCount;
ctx.body = entities;
return next();
}
);
@ -91,5 +101,10 @@ export default function organizationRoutes<T extends AuthedRouter>(
}
);
// MARK: Mount sub-routes
organizationRoleRoutes(...args);
organizationScopeRoutes(...args);
// Add routes to the router
originalRouter.use(router.routes());
}

View file

@ -11,7 +11,7 @@ import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import SchemaRouter, { SchemaActions } from '#src/utils/SchemaRouter.js';
import { type AuthedRouter, type RouterInitArgs } from './types.js';
import { type AuthedRouter, type RouterInitArgs } from '../types.js';
class OrganizationRoleActions extends SchemaActions<
OrganizationRoleKeys,

View file

@ -2,7 +2,7 @@ import { UniqueIntegrityConstraintViolationError } from 'slonik';
import RequestError from '#src/errors/RequestError/index.js';
import { OrganizationScopeActions } from './organization-scopes.js';
import { OrganizationScopeActions } from './scopes.js';
describe('OrganizationScopeActions', () => {
it('should throw RequestError if UniqueIntegrityConstraintViolationError is thrown inside', async () => {

View file

@ -9,7 +9,7 @@ import { UniqueIntegrityConstraintViolationError } from 'slonik';
import RequestError from '#src/errors/RequestError/index.js';
import SchemaRouter, { SchemaActions } from '#src/utils/SchemaRouter.js';
import { type AuthedRouter, type RouterInitArgs } from './types.js';
import { type AuthedRouter, type RouterInitArgs } from '../types.js';
export class OrganizationScopeActions extends SchemaActions<
OrganizationScopeKeys,

View file

@ -1,3 +1,4 @@
import { conditionalSql } from '@logto/shared';
import { type KeysToCamelCase } from '@silverhand/essentials';
import { sql, type CommonQueryMethods } from 'slonik';
import snakecaseKeys from 'snakecase-keys';
@ -17,8 +18,13 @@ type CamelCaseIdObject<T extends string> = KeysToCamelCase<{
[Key in `${T}_id`]: string;
}>;
type GetEntitiesOptions = {
limit?: number;
offset?: number;
};
/**
* Query class for relation tables that connect several tables by their entry ids.
* Query class for relation tables that connect several tables by their entity ids.
*
* Let's say we have two tables `users` and `groups` and a relation table
* `user_group_relations`. Then we can create a `RelationQueries` instance like this:
@ -41,13 +47,13 @@ type CamelCaseIdObject<T extends string> = KeysToCamelCase<{
* );
* ```
*
* To get all entries for a specific table, we can use the {@link RelationQueries.getEntries} method:
* To get all entities for a specific table, we can use the {@link RelationQueries.getEntities} method:
*
* ```ts
* await userGroupRelations.getEntries(Users, { groupId: 'group-id-1' });
* await userGroupRelations.getEntities(Users, { groupId: 'group-id-1' });
* ```
*
* This will return all entries for the `users` table that are connected to the
* This will return all entities for the `users` table that are connected to the
* group with the id `group-id-1`.
*/
export default class RelationQueries<
@ -75,12 +81,12 @@ export default class RelationQueries<
}
/**
* Insert new entries into the relation table.
* Insert new entities into the relation table.
*
* Each entry must contain the same number of ids as the number of relations, and
* Each entity must contain the same number of ids as the number of relations, and
* the order of the ids must match the order of the relations.
*
* @param data Entries to insert.
* @param data Entities to insert.
* @returns A Promise that resolves to the query result.
*
* @example
@ -117,7 +123,7 @@ export default class RelationQueries<
/**
* Delete a relation from the relation table.
*
* @param data The ids of the entries to delete. The keys must be in camel case
* @param data The ids of the entities to delete. The keys must be in camel case
* and end with `Id`.
* @returns A Promise that resolves to the query result.
*
@ -141,29 +147,34 @@ export default class RelationQueries<
}
/**
* Get all entries for a specific schema that are connected to the given ids.
* Get all entities for a specific schema that are connected to the given ids.
*
* @param forSchema The schema to get the entries for.
* @param where Other ids to filter the entries by. The keys must be in camel case
* @param forSchema The schema to get the entities for.
* @param where Other ids to filter the entities by. The keys must be in camel case
* and end with `Id`.
* @returns A Promise that resolves an array of entries of the given schema.
* @param options Options for the query.
* @param options.limit The maximum number of entities to return.
* @param options.offset The number of entities to skip.
* @returns A Promise that resolves an array of entities of the given schema.
*
* @example
* ```ts
* const userGroupRelations = new RelationQueries(pool, 'user_group_relations', Users, Groups);
*
* userGroupRelations.getEntries(Users, { groupId: 'group-id-1' });
* userGroupRelations.getEntities(Users, { groupId: 'group-id-1' });
* // With pagination
* userGroupRelations.getEntities(Users, { groupId: 'group-id-1' }, { limit: 10, offset: 20 });
* ```
*/
async getEntries<S extends Schemas[number]>(
async getEntities<S extends Schemas[number]>(
forSchema: S,
where: CamelCaseIdObject<Exclude<Schemas[number]['tableSingular'], S['tableSingular']>>
): Promise<ReadonlyArray<InferSchema<S>>> {
where: CamelCaseIdObject<Exclude<Schemas[number]['tableSingular'], S['tableSingular']>>,
options: GetEntitiesOptions = {}
): Promise<[totalCount: number, entities: ReadonlyArray<InferSchema<S>>]> {
const { limit, offset } = options;
const snakeCaseWhere = snakecaseKeys(where);
const forTable = sql.identifier([forSchema.table]);
const { rows } = await this.pool.query<InferSchema<S>>(sql`
select ${forTable}.*
const mainSql = sql`
from ${this.table}
join ${forTable} on ${sql.identifier([
this.relationTable,
@ -174,16 +185,30 @@ export default class RelationQueries<
([column, value]) => sql`${sql.identifier([column])} = ${value}`
),
sql` and `
)};
`);
)}
`;
return rows;
const [{ count }, { rows }] = await Promise.all([
// Postgres returns a bigint for count(*), which is then converted to a string by query library.
// We need to convert it to a number.
this.pool.one<{ count: string }>(sql`
select count(*)
${mainSql}
`),
this.pool.query<InferSchema<S>>(sql`
select ${forTable}.* ${mainSql}
${conditionalSql(limit, (limit) => sql`limit ${limit}`)}
${conditionalSql(offset, (offset) => sql`offset ${offset}`)}
`),
]);
return [Number(count), rows];
}
/**
* Check if a relation exists.
*
* @param ids The ids of the entries to check. The order of the ids must match the order of the relations.
* @param ids The ids of the entities to check. The order of the ids must match the order of the relations.
* @returns A Promise that resolves to `true` if the relation exists, otherwise `false`.
*
* @example

View file

@ -50,13 +50,15 @@ export class SchemaActions<
*
* @param pagination The request pagination info parsed from `koa-pagination`. The
* function should honor the pagination info and return the correct entities.
* @returns A tuple of `[count, entities]`. `count` is the total count of entities
* in the database; `entities` is the list of entities to be returned.
* @returns A tuple of `[totalCount, entities]`. `totalCount` is the total count of
* entities in the database; `entities` is the list of entities to be returned.
*/
public async get({
limit,
offset,
}: Pick<Pagination, 'limit' | 'offset'>): Promise<[count: number, entities: readonly Schema[]]> {
}: Pick<Pagination, 'limit' | 'offset'>): Promise<
[totalCount: number, entries: readonly Schema[]]
> {
return Promise.all([this.queries.findTotalNumber(), this.queries.findAll(limit, offset)]);
}
@ -289,9 +291,9 @@ export default class SchemaRouter<
relationSchemaIds: camelCaseSchemaId(relationSchema) + 's',
};
// TODO: Add pagination support
this.get(
`/:id/${pathname}`,
koaPagination(),
koaGuard({
params: z.object({ id: z.string().min(1) }),
response: relationSchema.guard.array(),
@ -303,9 +305,16 @@ export default class SchemaRouter<
// Ensure that the main entry exists
await this.actions.getById(id);
ctx.body = await relationQueries.getEntries(relationSchema, {
[columns.schemaId]: id,
});
const [totalCount, entities] = await relationQueries.getEntities(
relationSchema,
{
[columns.schemaId]: id,
},
ctx.pagination
);
ctx.pagination.totalCount = totalCount;
ctx.body = entities;
return next();
}
);

View file

@ -15,8 +15,10 @@ class OrganizationRoleApi extends ApiFactory<
await authedAdminApi.post(`${this.path}/${id}/scopes`, { json: { organizationScopeIds } });
}
async getScopes(id: string): Promise<OrganizationScope[]> {
return authedAdminApi.get(`${this.path}/${id}/scopes`).json<OrganizationScope[]>();
async getScopes(id: string, searchParams?: URLSearchParams): Promise<OrganizationScope[]> {
return authedAdminApi
.get(`${this.path}/${id}/scopes`, { searchParams })
.json<OrganizationScope[]>();
}
async deleteScope(id: string, scopeId: string): Promise<void> {

View file

@ -31,33 +31,30 @@ describe('organization role APIs', () => {
it('should be able to create a role with some scopes', async () => {
const name = 'test' + randomId();
const [scope1, scope2] = await Promise.all([
scopeApi.create({ name: 'test' + randomId() }),
scopeApi.create({ name: 'test' + randomId() }),
]);
const organizationScopeIds = [scope1.id, scope2.id];
const scopes = await Promise.all(
Array.from({ length: 20 }).map(async () => scopeApi.create({ name: 'test' + randomId() }))
);
const organizationScopeIds = scopes.map((scope) => scope.id);
const role = await roleApi.create({ name, organizationScopeIds });
expect(role).toStrictEqual(
expect.objectContaining({
name,
const roleScopes = await roleApi.getScopes(role.id);
expect(roleScopes).toHaveLength(20);
// Check pagination
const roleScopes2 = await roleApi.getScopes(
role.id,
new URLSearchParams({
page: '2',
page_size: '10',
})
);
// Check scopes under a role after API is implemented
const scopes = await roleApi.getScopes(role.id);
expect(scopes).toContainEqual(
expect.objectContaining({
name: scope1.name,
})
);
expect(scopes).toContainEqual(
expect.objectContaining({
name: scope2.name,
})
);
expect(roleScopes2).toHaveLength(10);
expect(roleScopes2[0]?.id).not.toBeFalsy();
expect(roleScopes2[0]?.id).toBe(roleScopes[10]?.id);
await Promise.all([scopeApi.delete(scope1.id), scopeApi.delete(scope2.id)]);
await Promise.all(scopes.map(async (scope) => scopeApi.delete(scope.id)));
await roleApi.delete(role.id);
});
it('should get organization roles successfully', async () => {