0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

refactor(core): organization apis error handling

This commit is contained in:
Gao Sun 2023-10-16 14:04:03 +08:00
parent 406e54e84f
commit c3219f6fcd
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
24 changed files with 177 additions and 83 deletions

View file

@ -10,6 +10,7 @@ import { type AuthedRouter, type RouterInitArgs } from '../types.js';
import organizationRoleRoutes from './roles.js';
import organizationScopeRoutes from './scopes.js';
import { errorHandler } from './utils.js';
export default function organizationRoutes<T extends AuthedRouter>(...args: RouterInitArgs<T>) {
const [
@ -18,7 +19,9 @@ export default function organizationRoutes<T extends AuthedRouter>(...args: Rout
queries: { organizations, users },
},
] = args;
const router = new SchemaRouter(Organizations, new SchemaActions(organizations));
const router = new SchemaRouter(Organizations, new SchemaActions(organizations), {
errorHandler,
});
router.addRelationRoutes(organizations.relations.users);

View file

@ -1,37 +1,12 @@
import {
type CreateOrganizationRole,
type OrganizationRole,
type OrganizationRoleKeys,
OrganizationRoles,
} from '@logto/schemas';
import { UniqueIntegrityConstraintViolationError } from 'slonik';
import { type CreateOrganizationRole, OrganizationRoles } from '@logto/schemas';
import { z } from 'zod';
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';
class OrganizationRoleActions extends SchemaActions<
OrganizationRoleKeys,
CreateOrganizationRole,
OrganizationRole
> {
override async post(
data: Omit<CreateOrganizationRole, 'id'>
): Promise<Readonly<OrganizationRole>> {
try {
return await super.post(data);
} catch (error: unknown) {
if (error instanceof UniqueIntegrityConstraintViolationError) {
throw new RequestError({ code: 'entity.duplicate_value_of_unique_field', field: 'name' });
}
throw error;
}
}
}
import { errorHandler } from './utils.js';
export default function organizationRoleRoutes<T extends AuthedRouter>(
...[
@ -46,8 +21,11 @@ export default function organizationRoleRoutes<T extends AuthedRouter>(
},
]: RouterInitArgs<T>
) {
const actions = new OrganizationRoleActions(roles);
const router = new SchemaRouter(OrganizationRoles, actions, { disabled: { post: true } });
const actions = new SchemaActions(roles);
const router = new SchemaRouter(OrganizationRoles, actions, {
disabled: { post: true },
errorHandler,
});
/** Allows to carry an initial set of scopes for creating a new organization role. */
type CreateOrganizationRolePayload = Omit<CreateOrganizationRole, 'id'> & {

View file

@ -1,20 +0,0 @@
import { UniqueIntegrityConstraintViolationError } from 'slonik';
import RequestError from '#src/errors/RequestError/index.js';
import { OrganizationScopeActions } from './scopes.js';
describe('OrganizationScopeActions', () => {
it('should throw RequestError if UniqueIntegrityConstraintViolationError is thrown inside', async () => {
// @ts-expect-error for testing
const actions = new OrganizationScopeActions({
insert: async () => {
throw new UniqueIntegrityConstraintViolationError(new Error('test'), 'unique');
},
});
await expect(actions.post({ name: 'test' })).rejects.toThrowError(
new RequestError({ code: 'entity.duplicate_value_of_unique_field', field: 'name' })
);
});
});

View file

@ -1,35 +1,10 @@
import {
type CreateOrganizationScope,
type OrganizationScope,
type OrganizationScopeKeys,
OrganizationScopes,
} from '@logto/schemas';
import { UniqueIntegrityConstraintViolationError } from 'slonik';
import { OrganizationScopes } from '@logto/schemas';
import RequestError from '#src/errors/RequestError/index.js';
import SchemaRouter, { SchemaActions } from '#src/utils/SchemaRouter.js';
import { type AuthedRouter, type RouterInitArgs } from '../types.js';
export class OrganizationScopeActions extends SchemaActions<
OrganizationScopeKeys,
CreateOrganizationScope,
OrganizationScope
> {
override async post(
data: Omit<CreateOrganizationScope, 'id'>
): Promise<Readonly<OrganizationScope>> {
try {
return await super.post(data);
} catch (error: unknown) {
if (error instanceof UniqueIntegrityConstraintViolationError) {
throw new RequestError({ code: 'entity.duplicate_value_of_unique_field', field: 'name' });
}
throw error;
}
}
}
import { errorHandler } from './utils.js';
export default function organizationScopeRoutes<T extends AuthedRouter>(
...[
@ -41,7 +16,7 @@ export default function organizationScopeRoutes<T extends AuthedRouter>(
},
]: RouterInitArgs<T>
) {
const router = new SchemaRouter(OrganizationScopes, new OrganizationScopeActions(scopes));
const router = new SchemaRouter(OrganizationScopes, new SchemaActions(scopes), { errorHandler });
originalRouter.use(router.routes());
}

View file

@ -0,0 +1,18 @@
import {
ForeignKeyIntegrityConstraintViolationError,
UniqueIntegrityConstraintViolationError,
} from 'slonik';
import RequestError from '#src/errors/RequestError/index.js';
export const errorHandler = (error: unknown) => {
if (error instanceof UniqueIntegrityConstraintViolationError) {
throw new RequestError({ code: 'entity.duplicate_value_of_unique_field', field: 'name' });
}
if (error instanceof ForeignKeyIntegrityConstraintViolationError) {
throw new RequestError({ code: 'entity.relation_foreign_key_not_found', status: 422 });
}
throw error;
};

View file

@ -4,6 +4,8 @@ import { sql, type CommonQueryMethods } from 'slonik';
import snakecaseKeys from 'snakecase-keys';
import { type z } from 'zod';
import { DeletionError } from '#src/errors/SlonikError/index.js';
type AtLeast2<T extends unknown[]> = `${T['length']}` extends '0' | '1' ? never : T;
type TableInfo<Table, TableSingular, Schema> = {
@ -135,7 +137,7 @@ export default class RelationQueries<
*/
async delete(data: CamelCaseIdObject<Schemas[number]['tableSingular']>) {
const snakeCaseData = snakecaseKeys(data);
return this.pool.query(sql`
const { rowCount } = await this.pool.query(sql`
delete from ${this.table}
where ${sql.join(
Object.entries(snakeCaseData).map(
@ -144,6 +146,10 @@ export default class RelationQueries<
sql` and `
)};
`);
if (rowCount < 1) {
throw new DeletionError(this.relationTable);
}
}
/**

View file

@ -90,7 +90,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.
* @throws An `UpdateError` if the entity is not found.
*/
public async patchById(id: string, data: Partial<Schema>): Promise<Readonly<Schema>> {
return this.queries.updateById(id, data);
@ -100,7 +100,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.
* @throws An `DeletionError` if the entity is not found.
*/
public async deleteById(id: string): Promise<void> {
return this.queries.deleteById(id);
@ -121,6 +121,7 @@ type SchemaRouterConfig = {
/** Disable `DELETE /:id` route. */
deleteById: boolean;
};
errorHandler?: (error: unknown) => void;
};
/**
@ -167,6 +168,17 @@ export default class SchemaRouter<
config
);
if (this.config.errorHandler) {
this.use(async (_, next) => {
try {
await next();
} catch (error: unknown) {
this.config.errorHandler?.(error);
throw error;
}
});
}
const { disabled } = this.config;
if (!disabled.get) {
@ -324,7 +336,7 @@ export default class SchemaRouter<
koaGuard({
params: z.object({ id: z.string().min(1) }),
body: z.object({ [columns.relationSchemaIds]: z.string().min(1).array().nonempty() }),
status: [201, 404, 422],
status: [201, 422],
}),
async (ctx, next) => {
const {
@ -344,7 +356,8 @@ export default class SchemaRouter<
`/:id/${pathname}/:relationId`,
koaGuard({
params: z.object({ id: z.string().min(1), relationId: z.string().min(1) }),
status: [204, 422],
// Should be 422 if the relation doesn't exist, update until we change the error handling
status: [204, 404],
}),
async (ctx, next) => {
const {

View file

@ -57,6 +57,23 @@ describe('organization role APIs', () => {
await roleApi.delete(role.id);
});
it('should fail to create a role with some scopes if the scopes do not exist', async () => {
const name = 'test' + randomId();
const organizationScopeIds = ['0'];
const response = await roleApi
.create({ name, organizationScopeIds })
.catch((error: unknown) => error);
assert(response instanceof HTTPError);
const { statusCode, body: raw } = response.response;
const body: unknown = JSON.parse(String(raw));
expect(statusCode).toBe(422);
expect(isKeyInObject(body, 'code') && body.code).toBe(
'entity.relation_foreign_key_not_found'
);
});
it('should get organization roles successfully', async () => {
const [name1, name2] = ['test' + randomId(), 'test' + randomId()];
const createdRoles = await Promise.all([
@ -165,6 +182,31 @@ describe('organization role APIs', () => {
]);
});
it('should fail when try to add non-existent 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() }),
]);
const response = await roleApi
.addScopes(role.id, [scope1.id, scope2.id, '0'])
.catch((error: unknown) => error);
assert(response instanceof HTTPError);
expect(response.response.statusCode).toBe(422);
expect(JSON.parse(String(response.response.body))).toMatchObject(
expect.objectContaining({
code: 'entity.relation_foreign_key_not_found',
})
);
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() }),
@ -192,5 +234,16 @@ describe('organization role APIs', () => {
scopeApi.delete(scope2.id),
]);
});
it('should fail when try to remove non-existent scopes from a role', async () => {
const [role] = await Promise.all([roleApi.create({ name: 'test' + randomId() })]);
const response = await roleApi.deleteScope(role.id, '0').catch((error: unknown) => error);
assert(response instanceof HTTPError);
expect(response.response.statusCode).toBe(404);
await Promise.all([roleApi.delete(role.id)]);
});
});
});

View file

@ -115,6 +115,24 @@ describe('organization APIs', () => {
]);
});
it('should fail when try to add empty user list', async () => {
const organization = await organizationApi.create({ name: 'test' });
const response = await organizationApi
.addUsers(organization.id, [])
.catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.statusCode).toBe(400);
await organizationApi.delete(organization.id);
});
it('should fail when try to add user to an organization that does not exist', async () => {
const response = await organizationApi.addUsers('0', ['0']).catch((error: unknown) => error);
assert(response instanceof HTTPError);
expect(response.response.statusCode).toBe(422);
expect(JSON.parse(String(response.response.body))).toMatchObject(
expect.objectContaining({ code: 'entity.relation_foreign_key_not_found' })
);
});
it('should be able to delete organization user', async () => {
const organization = await organizationApi.create({ name: 'test' });
const user = await createUser({ username: 'test' + randomId() });
@ -125,6 +143,12 @@ describe('organization APIs', () => {
expect(users).not.toContainEqual(user);
await Promise.all([organizationApi.delete(organization.id), deleteUser(user.id)]);
});
it('should fail when try to delete user from an organization that does not exist', async () => {
const response = await organizationApi.deleteUser('0', '0').catch((error: unknown) => error);
assert(response instanceof HTTPError);
expect(response.response.statusCode).toBe(404);
});
});
describe('organization - user - organization role relation routes', () => {

View file

@ -6,6 +6,9 @@ const entity = {
not_exists_with_id: '{{name}} mit ID `{{id}}` existiert nicht.',
not_found: 'Die Ressource wurde nicht gefunden.',
/** UNTRANSLATED */
relation_foreign_key_not_found:
'Cannot find one or more foreign keys. Please check the input and ensure that all referenced entities exist.',
/** UNTRANSLATED */
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
};

View file

@ -5,6 +5,8 @@ const entity = {
not_exists: 'The {{name}} does not exist.',
not_exists_with_id: 'The {{name}} with ID `{{id}}` does not exist.',
not_found: 'The resource does not exist.',
relation_foreign_key_not_found:
'Cannot find one or more foreign keys. Please check the input and ensure that all referenced entities exist.',
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
};

View file

@ -6,6 +6,9 @@ const entity = {
not_exists_with_id: 'El {{name}} con ID `{{id}}` no existe.',
not_found: 'El recurso no existe.',
/** UNTRANSLATED */
relation_foreign_key_not_found:
'Cannot find one or more foreign keys. Please check the input and ensure that all referenced entities exist.',
/** UNTRANSLATED */
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
};

View file

@ -6,6 +6,9 @@ const entity = {
not_exists_with_id: "Le {{name}} avec l'ID `{{id}}` n'existe pas.",
not_found: "La ressource n'existe pas.",
/** UNTRANSLATED */
relation_foreign_key_not_found:
'Cannot find one or more foreign keys. Please check the input and ensure that all referenced entities exist.',
/** UNTRANSLATED */
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
};

View file

@ -6,6 +6,9 @@ const entity = {
not_exists_with_id: '{{name}} con ID `{{id}}` non esiste.',
not_found: 'La risorsa non esiste.',
/** UNTRANSLATED */
relation_foreign_key_not_found:
'Cannot find one or more foreign keys. Please check the input and ensure that all referenced entities exist.',
/** UNTRANSLATED */
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
};

View file

@ -6,6 +6,9 @@ const entity = {
not_exists_with_id: 'IDが`{{id}}`の{{name}}は存在しません。',
not_found: 'リソースが存在しません。',
/** UNTRANSLATED */
relation_foreign_key_not_found:
'Cannot find one or more foreign keys. Please check the input and ensure that all referenced entities exist.',
/** UNTRANSLATED */
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
};

View file

@ -6,6 +6,9 @@ const entity = {
not_exists_with_id: '{{id}} ID를 가진 {{name}}는 존재하지 않아요.',
not_found: '리소스가 존재하지 않아요.',
/** UNTRANSLATED */
relation_foreign_key_not_found:
'Cannot find one or more foreign keys. Please check the input and ensure that all referenced entities exist.',
/** UNTRANSLATED */
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
};

View file

@ -6,6 +6,9 @@ const entity = {
not_exists_with_id: '{{name}} o identyfikatorze `{{id}}` nie istnieje.',
not_found: 'Zasób nie istnieje.',
/** UNTRANSLATED */
relation_foreign_key_not_found:
'Cannot find one or more foreign keys. Please check the input and ensure that all referenced entities exist.',
/** UNTRANSLATED */
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
};

View file

@ -6,6 +6,9 @@ const entity = {
not_exists_with_id: 'O {{name}} com ID `{{id}}` não existe.',
not_found: 'O recurso não existe.',
/** UNTRANSLATED */
relation_foreign_key_not_found:
'Cannot find one or more foreign keys. Please check the input and ensure that all referenced entities exist.',
/** UNTRANSLATED */
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
};

View file

@ -6,6 +6,9 @@ const entity = {
not_exists_with_id: '{{name}} com o ID `{{id}}` não existe.',
not_found: 'O recurso não existe.',
/** UNTRANSLATED */
relation_foreign_key_not_found:
'Cannot find one or more foreign keys. Please check the input and ensure that all referenced entities exist.',
/** UNTRANSLATED */
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
};

View file

@ -6,6 +6,9 @@ const entity = {
not_exists_with_id: '{{name}} с ID `{{id}}` не существует.',
not_found: 'Ресурс не существует.',
/** UNTRANSLATED */
relation_foreign_key_not_found:
'Cannot find one or more foreign keys. Please check the input and ensure that all referenced entities exist.',
/** UNTRANSLATED */
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
};

View file

@ -6,6 +6,9 @@ const entity = {
not_exists_with_id: ' `{{id}}` id kimliğine sahip {{name}} mevcut değil.',
not_found: 'Kaynak mevcut değil.',
/** UNTRANSLATED */
relation_foreign_key_not_found:
'Cannot find one or more foreign keys. Please check the input and ensure that all referenced entities exist.',
/** UNTRANSLATED */
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
};

View file

@ -6,6 +6,9 @@ const entity = {
not_exists_with_id: 'ID 为 `{{id}}` 的 {{name}} 不存在。',
not_found: '该资源不存在。',
/** UNTRANSLATED */
relation_foreign_key_not_found:
'Cannot find one or more foreign keys. Please check the input and ensure that all referenced entities exist.',
/** UNTRANSLATED */
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
};

View file

@ -6,6 +6,9 @@ const entity = {
not_exists_with_id: 'ID 為 `{{id}}` 的 {{name}} 不存在。',
not_found: '該資源不存在。',
/** UNTRANSLATED */
relation_foreign_key_not_found:
'Cannot find one or more foreign keys. Please check the input and ensure that all referenced entities exist.',
/** UNTRANSLATED */
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
};

View file

@ -6,6 +6,9 @@ const entity = {
not_exists_with_id: 'ID 為 `{{id}}` 的 {{name}} 不存在。',
not_found: '資源不存在。',
/** UNTRANSLATED */
relation_foreign_key_not_found:
'Cannot find one or more foreign keys. Please check the input and ensure that all referenced entities exist.',
/** UNTRANSLATED */
duplicate_value_of_unique_field: 'The value of the unique field `{{field}}` is duplicated.',
};