mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
refactor(core): organization crud apis
This commit is contained in:
parent
8e931d757f
commit
4110409bd1
8 changed files with 219 additions and 59 deletions
|
@ -1,5 +1,5 @@
|
|||
import { type Organization, Organizations } from '@logto/schemas';
|
||||
import { type OmitAutoSetFields } from '@logto/shared';
|
||||
import { type Organization, Organizations, type CreateOrganization } from '@logto/schemas';
|
||||
import { generateStandardId, type OmitAutoSetFields } from '@logto/shared';
|
||||
|
||||
import { buildDeleteByIdWithPool } from '#src/database/delete-by-id.js';
|
||||
import { buildFindAllEntitiesWithPool } from '#src/database/find-all-entities.js';
|
||||
|
@ -30,8 +30,10 @@ export default class OrganizationQueries extends TenantQueries {
|
|||
return this.#findById(id);
|
||||
}
|
||||
|
||||
async insert(data: OmitAutoSetFields<Organization>): Promise<Readonly<Organization>> {
|
||||
return this.#insert(data);
|
||||
async insert(
|
||||
data: Omit<OmitAutoSetFields<CreateOrganization>, 'id'>
|
||||
): Promise<Readonly<Organization>> {
|
||||
return this.#insert({ id: generateStandardId(), ...data });
|
||||
}
|
||||
|
||||
async updateById(
|
||||
|
|
1
packages/core/src/routes/organization-roles.ts
Normal file
1
packages/core/src/routes/organization-roles.ts
Normal file
|
@ -0,0 +1 @@
|
|||
function organizationRoleRoutes() {}
|
|
@ -1,9 +1,44 @@
|
|||
import { Organizations } from '@logto/schemas';
|
||||
import { type CreateOrganization, type Organization, Organizations } from '@logto/schemas';
|
||||
import { type OmitAutoSetFields } from '@logto/shared';
|
||||
|
||||
import SchemaRouter from '#src/utils/SchemaRouter.js';
|
||||
import { type Pagination } from '#src/middleware/koa-pagination.js';
|
||||
import type OrganizationQueries from '#src/queries/organizations.js';
|
||||
import SchemaRouter, { type SchemaActions } from '#src/utils/SchemaRouter.js';
|
||||
|
||||
import { type AuthedRouter, type RouterInitArgs } from './types.js';
|
||||
|
||||
type PostSchema = Omit<OmitAutoSetFields<CreateOrganization>, 'id'>;
|
||||
type PatchSchema = Partial<Omit<OmitAutoSetFields<Organization>, 'id'>>;
|
||||
|
||||
class OrganizationActions
|
||||
implements SchemaActions<CreateOrganization, Organization, PostSchema, PatchSchema>
|
||||
{
|
||||
postGuard = Organizations.createGuard.omit({ id: true, createdAt: true });
|
||||
patchGuard = Organizations.guard.omit({ id: true, createdAt: true }).partial();
|
||||
|
||||
constructor(public readonly queries: OrganizationQueries) {}
|
||||
|
||||
public async get({ limit, offset }: Pick<Pagination, 'limit' | 'offset'>) {
|
||||
return Promise.all([this.queries.findTotalNumber(), this.queries.findAll(limit, offset)]);
|
||||
}
|
||||
|
||||
public async getById(id: string) {
|
||||
return this.queries.findById(id);
|
||||
}
|
||||
|
||||
public async post(data: PostSchema) {
|
||||
return this.queries.insert(data);
|
||||
}
|
||||
|
||||
public async patchById(id: string, data: PatchSchema) {
|
||||
return this.queries.updateById(id, data);
|
||||
}
|
||||
|
||||
public async deleteById(id: string) {
|
||||
return this.queries.deleteById(id);
|
||||
}
|
||||
}
|
||||
|
||||
export default function organizationRoutes<T extends AuthedRouter>(
|
||||
...[
|
||||
originalRouter,
|
||||
|
@ -12,17 +47,7 @@ export default function organizationRoutes<T extends AuthedRouter>(
|
|||
},
|
||||
]: RouterInitArgs<T>
|
||||
) {
|
||||
const router = new SchemaRouter(Organizations, {
|
||||
get: async ({ limit, offset }) =>
|
||||
Promise.all([organizations.findTotalNumber(), organizations.findAll(limit, offset)]),
|
||||
getById: organizations.findById,
|
||||
post: organizations.insert,
|
||||
patchById: {
|
||||
guard: Organizations.guard.omit({ id: true, createdAt: true }).partial(),
|
||||
run: organizations.updateById,
|
||||
},
|
||||
deleteById: organizations.deleteById,
|
||||
});
|
||||
const router = new SchemaRouter(Organizations, new OrganizationActions(organizations));
|
||||
|
||||
originalRouter.use(router.routes());
|
||||
}
|
||||
|
|
|
@ -28,7 +28,7 @@ describe('SchemaRouter', () => {
|
|||
guard: z.object({ id: z.string() }),
|
||||
};
|
||||
const entities = [{ id: 'test' }, { id: 'test2' }] as const satisfies readonly Schema[];
|
||||
const actions: SchemaActions<CreateSchema, Schema, CreateSchema> = {
|
||||
const actions: SchemaActions<CreateSchema, Schema, CreateSchema, CreateSchema> = {
|
||||
get: jest.fn().mockResolvedValue([entities.length, entities]),
|
||||
getById: jest.fn(async (id) => {
|
||||
const entity = entities.find((entity) => entity.id === id);
|
||||
|
@ -37,11 +37,10 @@ describe('SchemaRouter', () => {
|
|||
}
|
||||
return entity;
|
||||
}),
|
||||
postGuard: z.object({ id: z.string().optional() }),
|
||||
post: jest.fn(async () => ({ id: 'test_new' })),
|
||||
patchById: {
|
||||
guard: z.object({ id: z.string().optional() }),
|
||||
run: jest.fn(async (id, data) => ({ id, ...data })),
|
||||
},
|
||||
patchGuard: z.object({ id: z.string().optional() }),
|
||||
patchById: jest.fn(async (id, data) => ({ id, ...data })),
|
||||
deleteById: jest.fn(),
|
||||
};
|
||||
const schemaRouter = new SchemaRouter(schema, actions);
|
||||
|
@ -56,7 +55,9 @@ describe('SchemaRouter', () => {
|
|||
it('should be able to get all entities', async () => {
|
||||
const response = await request.get(baseRoute);
|
||||
|
||||
expect(actions.get).toHaveBeenCalledWith(expect.objectContaining({ disabled: true }));
|
||||
expect(actions.get).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ disabled: false, offset: 0, limit: 20 })
|
||||
);
|
||||
expect(response.body).toStrictEqual(entities);
|
||||
});
|
||||
|
||||
|
@ -104,7 +105,7 @@ describe('SchemaRouter', () => {
|
|||
it('should be able to patch an entity by id', async () => {
|
||||
const response = await request.patch(`${baseRoute}/test`).send({ id: 'test_new' });
|
||||
|
||||
expect(actions.patchById.run).toHaveBeenCalledWith('test', { id: 'test_new' });
|
||||
expect(actions.patchById).toHaveBeenCalledWith('test', { id: 'test_new' });
|
||||
expect(response.body).toStrictEqual({ id: 'test_new' });
|
||||
expect(response.status).toEqual(200);
|
||||
});
|
||||
|
|
|
@ -6,62 +6,74 @@ import koaGuard from '#src/middleware/koa-guard.js';
|
|||
import koaPagination, { type Pagination } from '#src/middleware/koa-pagination.js';
|
||||
|
||||
/**
|
||||
* Actions configuration for a {@link SchemaRouter}.
|
||||
* Actions configuration for a {@link SchemaRouter}. It contains the
|
||||
* necessary functions to handle the CRUD operations for a schema.
|
||||
*/
|
||||
export type SchemaActions<
|
||||
export abstract class SchemaActions<
|
||||
CreateSchema extends SchemaLike,
|
||||
Schema extends CreateSchema,
|
||||
UpdateSchema extends Partial<Schema>,
|
||||
> = {
|
||||
PostSchema extends Partial<Schema>,
|
||||
PatchSchema extends Partial<Schema>,
|
||||
> {
|
||||
/**
|
||||
* The guard for the `POST /` request body. You can specify a partial
|
||||
* schema and disable some fields for business logic reasons, such as
|
||||
* id and createdAt.
|
||||
*
|
||||
* @see {@link post} for the function to create an entity.
|
||||
*/
|
||||
public abstract postGuard: z.ZodType<PostSchema>;
|
||||
|
||||
/**
|
||||
* The guard for the `PATCH /:id` request body. You can specify a
|
||||
* partial schema and disable some fields for business logic reasons.
|
||||
*
|
||||
* @see {@link patchById} for the function to update an entity.
|
||||
*/
|
||||
public abstract patchGuard: z.ZodType<PatchSchema>;
|
||||
|
||||
/**
|
||||
* The function for `GET /` route to get a list of entities.
|
||||
*
|
||||
* @param pagination The request pagination info parsed from `koa-pagination`. If
|
||||
* pagination is disabled, the function should return all entities.
|
||||
* @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.
|
||||
*/
|
||||
get: (pagination: Pagination) => Promise<[count: number, entities: readonly Schema[]]>;
|
||||
public abstract get(
|
||||
pagination: Pick<Pagination, 'limit' | 'offset'>
|
||||
): Promise<[count: number, entities: readonly Schema[]]>;
|
||||
/**
|
||||
* The function for `GET /:id` route to get an entity by ID.
|
||||
*
|
||||
* @param id The ID of the entity to be fetched.
|
||||
* @returns The entity to be returned.
|
||||
*/
|
||||
getById: (id: string) => Promise<Readonly<Schema>>;
|
||||
public abstract getById(id: string): Promise<Readonly<Schema>>;
|
||||
/**
|
||||
* The function for `POST /` route to create an entity.
|
||||
*
|
||||
* @param data The data of the entity to be created.
|
||||
* @returns The created entity.
|
||||
*/
|
||||
post: (data: CreateSchema) => Promise<Readonly<Schema>>;
|
||||
public abstract post(data: PostSchema): Promise<Readonly<Schema>>;
|
||||
|
||||
/**
|
||||
* The configuration for `PATCH /:id` route to update an entity by ID.
|
||||
* It contains a `guard` for the request body and a `run` function to update the entity.
|
||||
*/
|
||||
patchById: {
|
||||
/**
|
||||
* The guard for the request body. You can specify a partial schema and disable
|
||||
* some fields for business logic reasons.
|
||||
*/
|
||||
guard: z.ZodType<UpdateSchema>;
|
||||
/**
|
||||
* The function to update the entity by ID.
|
||||
* The function for `PATCH /:id` route to update the entity by ID.
|
||||
*
|
||||
* @param id The ID of the entity to be updated.
|
||||
* @param data The data of the entity to be updated.
|
||||
* @returns The updated entity.
|
||||
*/
|
||||
run: (id: string, data: UpdateSchema) => Promise<Readonly<Schema>>;
|
||||
};
|
||||
public abstract patchById(id: string, data: PatchSchema): Promise<Readonly<Schema>>;
|
||||
|
||||
/**
|
||||
* The function for `DELETE /:id` route to delete an entity by ID.
|
||||
*
|
||||
* @param id The ID of the entity to be deleted.
|
||||
*/
|
||||
deleteById: (id: string) => Promise<void>;
|
||||
};
|
||||
public abstract deleteById(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A standard RESTful router for a schema.
|
||||
|
@ -81,19 +93,20 @@ export type SchemaActions<
|
|||
export default class SchemaRouter<
|
||||
CreateSchema extends SchemaLike,
|
||||
Schema extends CreateSchema,
|
||||
UpdateSchema extends Partial<Schema> = Partial<Schema>,
|
||||
PostSchema extends Partial<Schema> = Partial<Schema>,
|
||||
PatchSchema extends Partial<Schema> = Partial<Schema>,
|
||||
StateT = unknown,
|
||||
CustomT extends IRouterParamContext = IRouterParamContext,
|
||||
> extends Router<StateT, CustomT> {
|
||||
constructor(
|
||||
public readonly schema: GeneratedSchema<CreateSchema, Schema>,
|
||||
public readonly actions: SchemaActions<CreateSchema, Schema, UpdateSchema>
|
||||
public readonly actions: SchemaActions<CreateSchema, Schema, PostSchema, PatchSchema>
|
||||
) {
|
||||
super({ prefix: '/' + schema.table.replaceAll('_', '-') });
|
||||
|
||||
this.get(
|
||||
'/',
|
||||
koaPagination({ isOptional: true }),
|
||||
koaPagination(),
|
||||
koaGuard({ response: schema.guard.array(), status: [200] }),
|
||||
async (ctx, next) => {
|
||||
const [count, entities] = await actions.get(ctx.pagination);
|
||||
|
@ -106,7 +119,7 @@ export default class SchemaRouter<
|
|||
this.post(
|
||||
'/',
|
||||
koaGuard({
|
||||
body: schema.createGuard,
|
||||
body: actions.postGuard,
|
||||
response: schema.guard,
|
||||
status: [201, 422],
|
||||
}),
|
||||
|
@ -134,12 +147,12 @@ export default class SchemaRouter<
|
|||
'/:id',
|
||||
koaGuard({
|
||||
params: z.object({ id: z.string().min(1) }),
|
||||
body: actions.patchById.guard,
|
||||
body: actions.patchGuard,
|
||||
response: schema.guard,
|
||||
status: [200, 404],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
ctx.body = await actions.patchById.run(ctx.guard.params.id, ctx.guard.body);
|
||||
ctx.body = await actions.patchById(ctx.guard.params.id, ctx.guard.body);
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { type CommonQueryMethods } from 'slonik';
|
||||
|
||||
export default abstract class TenantQueries {
|
||||
export default class TenantQueries {
|
||||
constructor(public readonly pool: CommonQueryMethods) {}
|
||||
}
|
||||
|
|
37
packages/integration-tests/src/api/organization.ts
Normal file
37
packages/integration-tests/src/api/organization.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { type Organization } from '@logto/schemas';
|
||||
|
||||
import { authedAdminApi } from './api.js';
|
||||
|
||||
export const createOrganization = async (name: string, description?: string) => {
|
||||
return authedAdminApi
|
||||
.post('organizations', {
|
||||
json: {
|
||||
name,
|
||||
description,
|
||||
},
|
||||
})
|
||||
.json<Organization>();
|
||||
};
|
||||
|
||||
export const getOrganizations = async (params?: URLSearchParams) => {
|
||||
return authedAdminApi.get('organizations?' + (params?.toString() ?? '')).json<Organization[]>();
|
||||
};
|
||||
|
||||
export const getOrganization = async (id: string) => {
|
||||
return authedAdminApi.get('organizations/' + id).json<Organization>();
|
||||
};
|
||||
|
||||
export const updateOrganization = async (id: string, name: string, description?: string) => {
|
||||
return authedAdminApi
|
||||
.patch('organizations/' + id, {
|
||||
json: {
|
||||
name,
|
||||
description,
|
||||
},
|
||||
})
|
||||
.json<Organization>();
|
||||
};
|
||||
|
||||
export const deleteOrganization = async (id: string) => {
|
||||
return authedAdminApi.delete('organizations/' + id);
|
||||
};
|
|
@ -0,0 +1,81 @@
|
|||
import { HTTPError } from 'got';
|
||||
|
||||
import {
|
||||
createOrganization,
|
||||
deleteOrganization,
|
||||
getOrganization,
|
||||
getOrganizations,
|
||||
updateOrganization,
|
||||
} from '#src/api/organization.js';
|
||||
|
||||
describe('organizations', () => {
|
||||
it('should get organizations successfully', async () => {
|
||||
await createOrganization('test', 'A test organization.');
|
||||
await createOrganization('test2');
|
||||
const organizations = await getOrganizations();
|
||||
|
||||
expect(organizations).toContainEqual(
|
||||
expect.objectContaining({ name: 'test', description: 'A test organization.' })
|
||||
);
|
||||
expect(organizations).toContainEqual(
|
||||
expect.objectContaining({ name: 'test2', description: null })
|
||||
);
|
||||
});
|
||||
|
||||
it('should get organizations with pagination', async () => {
|
||||
// Add 20 organizations to exceed the default page size
|
||||
await Promise.all(Array.from({ length: 30 }).map(async () => createOrganization('test')));
|
||||
|
||||
const organizations = await getOrganizations();
|
||||
expect(organizations).toHaveLength(20);
|
||||
|
||||
const organizations2 = await getOrganizations(
|
||||
new URLSearchParams({
|
||||
page: '2',
|
||||
page_size: '10',
|
||||
})
|
||||
);
|
||||
expect(organizations2.length).toBeGreaterThanOrEqual(10);
|
||||
expect(organizations2[0]?.id).not.toBeFalsy();
|
||||
expect(organizations2[0]?.id).toBe(organizations[10]?.id);
|
||||
});
|
||||
|
||||
it('should be able to create and get organizations by id', async () => {
|
||||
const createdOrganization = await createOrganization('test');
|
||||
const organization = await getOrganization(createdOrganization.id);
|
||||
|
||||
expect(organization).toStrictEqual(createdOrganization);
|
||||
});
|
||||
|
||||
it('should fail when try to get an organization that does not exist', async () => {
|
||||
const response = await getOrganization('0').catch((error: unknown) => error);
|
||||
|
||||
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('should be able to update organization', async () => {
|
||||
const createdOrganization = await createOrganization('test');
|
||||
const organization = await updateOrganization(
|
||||
createdOrganization.id,
|
||||
'test2',
|
||||
'test description.'
|
||||
);
|
||||
expect(organization).toStrictEqual({
|
||||
...createdOrganization,
|
||||
name: 'test2',
|
||||
description: 'test description.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to delete organization', async () => {
|
||||
const createdOrganization = await createOrganization('test');
|
||||
await deleteOrganization(createdOrganization.id);
|
||||
const response = await getOrganization(createdOrganization.id).catch((error: unknown) => error);
|
||||
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('should fail when try to delete an organization that does not exist', async () => {
|
||||
const response = await deleteOrganization('0').catch((error: unknown) => error);
|
||||
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue