diff --git a/packages/core/src/queries/organizations.ts b/packages/core/src/queries/organizations.ts index fb55d2b36..5e15acdef 100644 --- a/packages/core/src/queries/organizations.ts +++ b/packages/core/src/queries/organizations.ts @@ -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): Promise> { - return this.#insert(data); + async insert( + data: Omit, 'id'> + ): Promise> { + return this.#insert({ id: generateStandardId(), ...data }); } async updateById( diff --git a/packages/core/src/routes/organization-roles.ts b/packages/core/src/routes/organization-roles.ts new file mode 100644 index 000000000..9f0a57053 --- /dev/null +++ b/packages/core/src/routes/organization-roles.ts @@ -0,0 +1 @@ +function organizationRoleRoutes() {} diff --git a/packages/core/src/routes/organizations.ts b/packages/core/src/routes/organizations.ts index 3f3a1933f..343199196 100644 --- a/packages/core/src/routes/organizations.ts +++ b/packages/core/src/routes/organizations.ts @@ -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, 'id'>; +type PatchSchema = Partial, 'id'>>; + +class OrganizationActions + implements SchemaActions +{ + 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) { + 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( ...[ originalRouter, @@ -12,17 +47,7 @@ export default function organizationRoutes( }, ]: RouterInitArgs ) { - 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()); } diff --git a/packages/core/src/utils/SchemaRouter.test.ts b/packages/core/src/utils/SchemaRouter.test.ts index cd4381a17..5b0cf0386 100644 --- a/packages/core/src/utils/SchemaRouter.test.ts +++ b/packages/core/src/utils/SchemaRouter.test.ts @@ -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 = { + const actions: SchemaActions = { 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); }); diff --git a/packages/core/src/utils/SchemaRouter.ts b/packages/core/src/utils/SchemaRouter.ts index f1b3e193d..fc867e69f 100644 --- a/packages/core/src/utils/SchemaRouter.ts +++ b/packages/core/src/utils/SchemaRouter.ts @@ -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, -> = { + PostSchema extends Partial, + PatchSchema extends Partial, +> { + /** + * 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; + + /** + * 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; + /** * 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 + ): 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>; + public abstract getById(id: string): Promise>; /** * 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>; + public abstract post(data: PostSchema): Promise>; + /** - * 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. + * 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. */ - patchById: { - /** - * The guard for the request body. You can specify a partial schema and disable - * some fields for business logic reasons. - */ - guard: z.ZodType; - /** - * The function 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>; - }; + public abstract patchById(id: string, data: PatchSchema): Promise>; + /** * 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; -}; + public abstract deleteById(id: string): Promise; +} /** * 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 = Partial, + PostSchema extends Partial = Partial, + PatchSchema extends Partial = Partial, StateT = unknown, CustomT extends IRouterParamContext = IRouterParamContext, > extends Router { constructor( public readonly schema: GeneratedSchema, - public readonly actions: SchemaActions + public readonly actions: SchemaActions ) { 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(); } ); diff --git a/packages/core/src/utils/TenantQueries.ts b/packages/core/src/utils/TenantQueries.ts index b4e5b0c8e..88c023442 100644 --- a/packages/core/src/utils/TenantQueries.ts +++ b/packages/core/src/utils/TenantQueries.ts @@ -1,5 +1,5 @@ import { type CommonQueryMethods } from 'slonik'; -export default abstract class TenantQueries { +export default class TenantQueries { constructor(public readonly pool: CommonQueryMethods) {} } diff --git a/packages/integration-tests/src/api/organization.ts b/packages/integration-tests/src/api/organization.ts new file mode 100644 index 000000000..f039b6b0f --- /dev/null +++ b/packages/integration-tests/src/api/organization.ts @@ -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(); +}; + +export const getOrganizations = async (params?: URLSearchParams) => { + return authedAdminApi.get('organizations?' + (params?.toString() ?? '')).json(); +}; + +export const getOrganization = async (id: string) => { + return authedAdminApi.get('organizations/' + id).json(); +}; + +export const updateOrganization = async (id: string, name: string, description?: string) => { + return authedAdminApi + .patch('organizations/' + id, { + json: { + name, + description, + }, + }) + .json(); +}; + +export const deleteOrganization = async (id: string) => { + return authedAdminApi.delete('organizations/' + id); +}; diff --git a/packages/integration-tests/src/tests/api/organization.test.ts b/packages/integration-tests/src/tests/api/organization.test.ts new file mode 100644 index 000000000..4f19b0b70 --- /dev/null +++ b/packages/integration-tests/src/tests/api/organization.test.ts @@ -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); + }); +});