0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

Merge pull request #4657 from logto-io/gao-org-apis-4

feat(core): role - scope relation apis
This commit is contained in:
Gao Sun 2023-10-16 02:15:18 -05:00 committed by GitHub
commit d69a73617e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 375 additions and 115 deletions

View file

@ -28,8 +28,8 @@ export default class OrganizationQueries extends SchemaQueries<
rolesScopes: new RelationQueries(
this.pool,
OrganizationRoleScopeRelations.table,
OrganizationRoles.table,
OrganizationScopes.table
OrganizationRoles,
OrganizationScopes
),
};

View file

@ -3,6 +3,7 @@ import {
type OrganizationRole,
type OrganizationRoleKeys,
OrganizationRoles,
OrganizationScopes,
} from '@logto/schemas';
import { UniqueIntegrityConstraintViolationError } from 'slonik';
import { z } from 'zod';
@ -82,5 +83,63 @@ export default function organizationRoleRoutes<T extends AuthedRouter>(
}
);
// MARK: Role - scope relations routes
router.get(
'/:id/scopes',
koaGuard({
params: z.object({ id: z.string().min(1) }),
response: OrganizationScopes.guard.array(),
status: [200, 404],
}),
async (ctx, next) => {
const { id } = ctx.guard.params;
// Ensure that role exists
await actions.getById(id);
ctx.body = await rolesScopes.getEntries(OrganizationScopes, { organizationRoleId: id });
return next();
}
);
router.post(
'/:id/scopes',
koaGuard({
params: z.object({ id: z.string().min(1) }),
body: z.object({ scopeIds: z.string().min(1).array().nonempty() }),
response: OrganizationScopes.guard.array(),
status: [200, 404, 422],
}),
async (ctx, next) => {
const {
params: { id },
body: { scopeIds },
} = ctx.guard;
await rolesScopes.insert(...scopeIds.map<[string, string]>((scopeId) => [id, scopeId]));
ctx.body = await rolesScopes.getEntries(OrganizationScopes, { organizationRoleId: id });
return next();
}
);
router.delete(
'/:id/scopes/:scopeId',
koaGuard({
params: z.object({ id: z.string().min(1), scopeId: z.string().min(1) }),
status: [204, 422],
}),
async (ctx, next) => {
const {
params: { id, scopeId },
} = ctx.guard;
await rolesScopes.delete({ organizationRoleId: id, organizationScopeId: scopeId });
ctx.status = 204;
return next();
}
);
originalRouter.use(router.routes());
}

View file

@ -1,21 +1,35 @@
import pluralize from 'pluralize';
import { type KeysToCamelCase } from '@silverhand/essentials';
import { sql, type CommonQueryMethods } from 'slonik';
import snakecaseKeys from 'snakecase-keys';
import { type z } from 'zod';
type AtLeast2<T extends unknown[]> = `${T['length']}` extends '0' | '1' ? never : T;
type RemoveLiteral<T extends string, L extends string> = T extends L ? Exclude<T, L> : T;
type TableInfo<Table, TableSingular, Schema> = {
table: Table;
tableSingular: TableSingular;
guard: z.ZodType<Schema, z.ZodTypeDef, unknown>;
};
type InferSchema<T> = T extends TableInfo<infer _, infer _, infer Schema> ? Schema : never;
type CamelCaseIdObject<T extends string> = KeysToCamelCase<{
[Key in `${T}_id`]: string;
}>;
/**
* Query class for relation tables that connect several tables by their entry ids.
*
* @example
* 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:
*
* ```ts
* const userGroupRelations = new RelationQueries(pool, 'user_group_relations', 'users', 'groups');
* const userGroupRelations = new RelationQueries(pool, 'user_group_relations', Users, Groups);
* ```
*
* `Users` and `Groups` are the schemas of the tables that satisfy the {@link TableInfo}
* interface. The generated schemas in `@logto/schemas` satisfy this interface.
*
* To insert a new relation, we can use the {@link RelationQueries.insert} method:
*
* ```ts
@ -30,33 +44,34 @@ type RemoveLiteral<T extends string, L extends string> = T extends L ? Exclude<T
* To get all entries for a specific table, we can use the {@link RelationQueries.getEntries} method:
*
* ```ts
* await userGroupRelations.getEntries('users', { group_id: 'group-id-1' });
* await userGroupRelations.getEntries(Users, { groupId: 'group-id-1' });
* ```
*
* This will return all entries for the `users` table that are connected to the
* group with the id `group-id-1`.
*/
export default class RelationQueries<
SnakeCaseRelations extends Array<Lowercase<string>>,
Length = AtLeast2<SnakeCaseRelations>['length'],
Schemas extends Array<TableInfo<string, string, unknown>>,
Length = AtLeast2<Schemas>['length'],
> {
protected get table() {
return sql.identifier([this.relationTable]);
}
public readonly relations: SnakeCaseRelations;
public readonly schemas: Schemas;
/**
* @param pool The database pool.
* @param relationTable The name of the relation table.
* @param relations The names of the tables that are connected by the relation table.
* @param relations The schemas of the tables that are connected by the relation table.
* @see {@link TableInfo} for more information about the schemas.
*/
constructor(
public readonly pool: CommonQueryMethods,
public readonly relationTable: string,
...relations: Readonly<SnakeCaseRelations>
...schemas: Readonly<Schemas>
) {
this.relations = relations;
this.schemas = schemas;
}
/**
@ -67,7 +82,7 @@ export default class RelationQueries<
*
* @example
* ```ts
* const userGroupRelations = new RelationQueries(pool, 'user_group_relations', 'users', 'groups');
* const userGroupRelations = new RelationQueries(pool, 'user_group_relations', Users, Groups);
*
* userGroupRelations.insert(['user-id-1', 'group-id-1']);
* // Insert multiple relations at once
@ -83,7 +98,7 @@ export default class RelationQueries<
async insert(...data: ReadonlyArray<string[] & { length: Length }>) {
return this.pool.query(sql`
insert into ${this.table} (${sql.join(
this.relations.map((relation) => sql.identifier([pluralize(relation, 1) + '_id'])),
this.schemas.map(({ tableSingular }) => sql.identifier([tableSingular + '_id'])),
sql`, `
)})
values ${sql.join(
@ -99,10 +114,69 @@ export default class RelationQueries<
`);
}
async getEntries<L extends SnakeCaseRelations[number]>(
forRelation: L,
where: Record<RemoveLiteral<SnakeCaseRelations[number], L>, unknown>
) {
throw new Error('Not implemented');
/**
* Delete a relation from the relation table.
*
* @param data The ids of the entries to delete. The keys must be in camel case
* and end with `Id`.
* @returns A Promise that resolves to the query result.
*
* @example
* ```ts
* const userGroupRelations = new RelationQueries(pool, 'user_group_relations', Users, Groups);
* userGroupRelations.delete({ userId: 'user-id-1', groupId: 'group-id-1' });
* ```
*/
async delete(data: CamelCaseIdObject<Schemas[number]['tableSingular']>) {
const snakeCaseData = snakecaseKeys(data);
return this.pool.query(sql`
delete from ${this.table}
where ${sql.join(
Object.entries(snakeCaseData).map(
([column, value]) => sql`${sql.identifier([column])} = ${value}`
),
sql` and `
)};
`);
}
/**
* Get all entries 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
* and end with `Id`.
* @returns A Promise that resolves an array of entries of the given schema.
*
* @example
* ```ts
* const userGroupRelations = new RelationQueries(pool, 'user_group_relations', Users, Groups);
*
* userGroupRelations.getEntries(Users, { groupId: 'group-id-1' });
* ```
*/
async getEntries<S extends Schemas[number]>(
forSchema: S,
where: CamelCaseIdObject<Exclude<Schemas[number]['tableSingular'], S['tableSingular']>>
): Promise<ReadonlyArray<InferSchema<S>>> {
const snakeCaseWhere = snakecaseKeys(where);
const forTable = sql.identifier([forSchema.table]);
const { rows } = await this.pool.query<InferSchema<S>>(sql`
select ${forTable}.*
from ${this.table}
join ${forTable} on ${sql.identifier([
this.relationTable,
forSchema.tableSingular + '_id',
])} = ${forTable}.id
where ${sql.join(
Object.entries(snakeCaseWhere).map(
([column, value]) => sql`${sql.identifier([column])} = ${value}`
),
sql` and `
)};
`);
return rows;
}
}

View file

@ -41,6 +41,7 @@ export class SchemaActions<
*
* @param id The ID of the entity to be fetched.
* @returns The entity to be returned.
* @throws An `RequestError` with 404 status code if the entity is not found.
*/
public async getById(id: string): Promise<Readonly<Schema>> {
return this.queries.findById(id);
@ -63,6 +64,7 @@ export class SchemaActions<
* @param id The ID of the entity to be updated.
* @param data The data of the entity to be updated.
* @returns The updated entity.
* @throws An `RequestError` with 404 status code if the entity is not found.
*/
public async patchById(id: string, data: Partial<Schema>): Promise<Readonly<Schema>> {
return this.queries.updateById(id, data);
@ -72,6 +74,7 @@ export class SchemaActions<
* The function for `DELETE /:id` route to delete an entity by ID.
*
* @param id The ID of the entity to be deleted.
* @throws An `RequestError` with 404 status code if the entity is not found.
*/
public async deleteById(id: string): Promise<void> {
return this.queries.deleteById(id);

View file

@ -1,5 +1,6 @@
import { type OrganizationRole } from '@logto/schemas';
import { type OrganizationScope, type OrganizationRole } from '@logto/schemas';
import { authedAdminApi } from './api.js';
import { ApiFactory } from './factory.js';
class OrganizationRoleApi extends ApiFactory<
@ -9,6 +10,18 @@ class OrganizationRoleApi extends ApiFactory<
constructor() {
super('organization-roles');
}
async addScopes(id: string, scopeIds: string[]): Promise<void> {
await authedAdminApi.post(`${this.path}/${id}/scopes`, { json: { scopeIds } });
}
async getScopes(id: string): Promise<OrganizationScope[]> {
return authedAdminApi.get(`${this.path}/${id}/scopes`).json<OrganizationScope[]>();
}
async deleteScope(id: string, scopeId: string): Promise<void> {
await authedAdminApi.delete(`${this.path}/${id}/scopes/${scopeId}`);
}
}
export const roleApi = new OrganizationRoleApi();

View file

@ -9,107 +9,218 @@ import { scopeApi } from '#src/api/organization-scope.js';
const randomId = () => generateStandardId(4);
describe('organization roles', () => {
it('should fail if the name of the new organization role already exists', async () => {
const name = 'test' + randomId();
await roleApi.create({ name });
const response = await roleApi.create({ name }).catch((error: unknown) => error);
// Add additional layer of describe to run tests in band
describe('organization role APIs', () => {
describe('organization roles', () => {
it('should fail if the name of the new organization role already exists', async () => {
const name = 'test' + randomId();
const createdRole = await roleApi.create({ name });
const response = await roleApi.create({ name }).catch((error: unknown) => error);
assert(response instanceof HTTPError);
assert(response instanceof HTTPError);
const { statusCode, body: raw } = response.response;
const body: unknown = JSON.parse(String(raw));
expect(statusCode).toBe(400);
expect(isKeyInObject(body, 'code') && body.code).toBe('entity.duplicate_value_of_unique_field');
});
const { statusCode, body: raw } = response.response;
const body: unknown = JSON.parse(String(raw));
expect(statusCode).toBe(400);
expect(isKeyInObject(body, 'code') && body.code).toBe(
'entity.duplicate_value_of_unique_field'
);
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 scopeIds = [scope1.id, scope2.id];
const role = await roleApi.create({ name, scopeIds });
expect(role).toStrictEqual(
expect.objectContaining({
name,
})
);
// TODO: Check scopes under a role after API is implemented
await Promise.all([scopeApi.delete(scope1.id), scopeApi.delete(scope2.id)]);
});
it('should get organization roles successfully', async () => {
const [name1, name2] = ['test' + randomId(), 'test' + randomId()];
await roleApi.create({ name: name1, description: 'A test organization role.' });
await roleApi.create({ name: name2 });
const roles = await roleApi.getList();
expect(roles).toContainEqual(
expect.objectContaining({ name: name1, description: 'A test organization role.' })
);
expect(roles).toContainEqual(expect.objectContaining({ name: name2, description: null }));
});
it('should get organization roles with pagination', async () => {
// Add 20 roles to exceed the default page size
await Promise.all(
Array.from({ length: 30 }).map(async () => roleApi.create({ name: 'test' + randomId() }))
);
const roles = await roleApi.getList();
expect(roles).toHaveLength(20);
const roles2 = await roleApi.getList(
new URLSearchParams({
page: '2',
page_size: '10',
})
);
expect(roles2.length).toBeGreaterThanOrEqual(10);
expect(roles2[0]?.id).not.toBeFalsy();
expect(roles2[0]?.id).toBe(roles[10]?.id);
});
it('should be able to create and get organization roles by id', async () => {
const createdRole = await roleApi.create({ name: 'test' + randomId() });
const role = await roleApi.get(createdRole.id);
expect(role).toStrictEqual(createdRole);
});
it('should fail when try to get an organization role that does not exist', async () => {
const response = await roleApi.get('0').catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
});
it('should be able to update organization role', async () => {
const createdRole = await roleApi.create({ name: 'test' + randomId() });
const newName = 'test' + randomId();
const role = await roleApi.update(createdRole.id, {
name: newName,
description: 'test description.',
await roleApi.delete(createdRole.id);
});
expect(role).toStrictEqual({
...createdRole,
name: newName,
description: 'test description.',
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 scopeIds = [scope1.id, scope2.id];
const role = await roleApi.create({ name, scopeIds });
expect(role).toStrictEqual(
expect.objectContaining({
name,
})
);
// 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,
})
);
await Promise.all([scopeApi.delete(scope1.id), scopeApi.delete(scope2.id)]);
});
it('should get organization roles successfully', async () => {
const [name1, name2] = ['test' + randomId(), 'test' + randomId()];
const createdRoles = await Promise.all([
roleApi.create({ name: name1, description: 'A test organization role.' }),
roleApi.create({ name: name2 }),
]);
const roles = await roleApi.getList();
expect(roles).toContainEqual(
expect.objectContaining({ name: name1, description: 'A test organization role.' })
);
expect(roles).toContainEqual(expect.objectContaining({ name: name2, description: null }));
await Promise.all(createdRoles.map(async (role) => roleApi.delete(role.id)));
});
it('should get organization roles with pagination', async () => {
// Add 20 roles to exceed the default page size
const allRoles = await Promise.all(
Array.from({ length: 30 }).map(async () => roleApi.create({ name: 'test' + randomId() }))
);
const roles = await roleApi.getList();
expect(roles).toHaveLength(20);
const roles2 = await roleApi.getList(
new URLSearchParams({
page: '2',
page_size: '10',
})
);
expect(roles2.length).toBeGreaterThanOrEqual(10);
expect(roles2[0]?.id).not.toBeFalsy();
expect(roles2[0]?.id).toBe(roles[10]?.id);
await Promise.all(allRoles.map(async (role) => roleApi.delete(role.id)));
});
it('should be able to create and get organization roles by id', async () => {
const createdRole = await roleApi.create({ name: 'test' + randomId() });
const role = await roleApi.get(createdRole.id);
expect(role).toStrictEqual(createdRole);
await roleApi.delete(createdRole.id);
});
it('should fail when try to get an organization role that does not exist', async () => {
const response = await roleApi.get('0').catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
});
it('should be able to update organization role', async () => {
const createdRole = await roleApi.create({ name: 'test' + randomId() });
const newName = 'test' + randomId();
const role = await roleApi.update(createdRole.id, {
name: newName,
description: 'test description.',
});
expect(role).toStrictEqual({
...createdRole,
name: newName,
description: 'test description.',
});
await roleApi.delete(createdRole.id);
});
it('should be able to delete organization role', async () => {
const createdRole = await roleApi.create({ name: 'test' + randomId() });
await roleApi.delete(createdRole.id);
const response = await roleApi.get(createdRole.id).catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
});
it('should fail when try to delete an organization role that does not exist', async () => {
const response = await roleApi.delete('0').catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
});
});
it('should be able to delete organization role', async () => {
const createdRole = await roleApi.create({ name: 'test' + randomId() });
await roleApi.delete(createdRole.id);
const response = await roleApi.get(createdRole.id).catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
});
describe('organization role - scope relations', () => {
it('should be able to get scopes of a role', async () => {
const [role, scope1, scope2] = await Promise.all([
roleApi.create({ name: 'test' + randomId() }),
scopeApi.create({ name: 'test' + randomId() }),
scopeApi.create({ name: 'test' + randomId() }),
]);
await roleApi.addScopes(role.id, [scope1.id, scope2.id]);
const scopes = await roleApi.getScopes(role.id);
it('should fail when try to delete an organization role that does not exist', async () => {
const response = await roleApi.delete('0').catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
expect(scopes).toContainEqual(
expect.objectContaining({
name: scope1.name,
})
);
expect(scopes).toContainEqual(
expect.objectContaining({
name: scope2.name,
})
);
await Promise.all([
roleApi.delete(role.id),
scopeApi.delete(scope1.id),
scopeApi.delete(scope2.id),
]);
});
it('should be able to add scopes to a role', async () => {
const [role, scope1, scope2] = await Promise.all([
roleApi.create({ name: 'test' + randomId() }),
scopeApi.create({ name: 'test' + randomId() }),
scopeApi.create({ name: 'test' + randomId() }),
]);
await roleApi.addScopes(role.id, [scope1.id, scope2.id]);
const scopes = await roleApi.getScopes(role.id);
expect(scopes).toContainEqual(
expect.objectContaining({
name: scope1.name,
})
);
expect(scopes).toContainEqual(
expect.objectContaining({
name: scope2.name,
})
);
await Promise.all([
roleApi.delete(role.id),
scopeApi.delete(scope1.id),
scopeApi.delete(scope2.id),
]);
});
it('should be able to remove scopes from a role', async () => {
const [role, scope1, scope2] = await Promise.all([
roleApi.create({ name: 'test' + randomId() }),
scopeApi.create({ name: 'test' + randomId() }),
scopeApi.create({ name: 'test' + randomId() }),
]);
await roleApi.addScopes(role.id, [scope1.id, scope2.id]);
await roleApi.deleteScope(role.id, scope1.id);
const scopes = await roleApi.getScopes(role.id);
expect(scopes).not.toContainEqual(
expect.objectContaining({
name: scope1.name,
})
);
expect(scopes).toContainEqual(
expect.objectContaining({
name: scope2.name,
})
);
await Promise.all([
roleApi.delete(role.id),
scopeApi.delete(scope1.id),
scopeApi.delete(scope2.id),
]);
});
});
});