diff --git a/packages/core/src/database/delete-by-id.ts b/packages/core/src/database/delete-by-id.ts new file mode 100644 index 000000000..36d073e26 --- /dev/null +++ b/packages/core/src/database/delete-by-id.ts @@ -0,0 +1,15 @@ +import { type CommonQueryMethods, sql } from 'slonik'; + +import { DeletionError } from '#src/errors/SlonikError/index.js'; + +export const buildDeleteByIdWithPool = + (pool: CommonQueryMethods, table: string) => async (id: string) => { + const { rowCount } = await pool.query(sql` + delete from ${sql.identifier([table])} + where ${sql.identifier(['id'])}=${id}; + `); + + if (rowCount < 1) { + throw new DeletionError(table, id); + } + }; diff --git a/packages/core/src/database/row-count.ts b/packages/core/src/database/row-count.ts index 3d3c43921..940c7c44f 100644 --- a/packages/core/src/database/row-count.ts +++ b/packages/core/src/database/row-count.ts @@ -1,6 +1,18 @@ import type { CommonQueryMethods, IdentifierSqlToken } from 'slonik'; 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. + // We need to convert it to a number. + const { count } = await pool.one<{ count: string }>(sql` + select count(*) + from ${sql.identifier([table])} + `); + + return { count: Number(count) }; + }; + export const getTotalRowCountWithPool = (pool: CommonQueryMethods) => async (table: IdentifierSqlToken) => { // Postgres returns a biging for count(*), which is then converted to a string by query library. diff --git a/packages/core/src/middleware/koa-guard.ts b/packages/core/src/middleware/koa-guard.ts index 3a45f36ce..6373ed421 100644 --- a/packages/core/src/middleware/koa-guard.ts +++ b/packages/core/src/middleware/koa-guard.ts @@ -23,7 +23,7 @@ export type GuardConfig = { * // e.g. parse '?key1=foo' * z.object({ key1: z.string() }) */ - query?: ZodType; + query?: ZodType; /** * Guard JSON request body. You can treat the body like a normal object. * @@ -33,7 +33,7 @@ export type GuardConfig = { * key2: z.object({ key3: z.number() }).array(), * }) */ - body?: ZodType; + body?: ZodType; /** * Guard `koa-router` path parameters (i.e. `ctx.params`). * @@ -41,19 +41,19 @@ export type GuardConfig = { * // e.g. parse '/foo/:key1' * z.object({ key1: z.string() }) */ - params?: ZodType; + params?: ZodType; /** * Guard response body. * * @example z.object({ key1: z.string() }) */ - response?: ZodType; + response?: ZodType; /** * Guard response status code. It produces a `ServerError` (500) * if the response does not satisfy any of the given value(s). */ status?: number | number[]; - files?: ZodType; + files?: ZodType; }; export type GuardedRequest = { diff --git a/packages/core/src/middleware/koa-pagination.ts b/packages/core/src/middleware/koa-pagination.ts index 940f8e1dc..29e53266f 100644 --- a/packages/core/src/middleware/koa-pagination.ts +++ b/packages/core/src/middleware/koa-pagination.ts @@ -5,7 +5,7 @@ import { number } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; import { buildLink } from '#src/utils/pagination.js'; -type Pagination = { +export type Pagination = { offset: number; limit: number; totalCount?: number; diff --git a/packages/core/src/queries/organizations.ts b/packages/core/src/queries/organizations.ts new file mode 100644 index 000000000..5e15acdef --- /dev/null +++ b/packages/core/src/queries/organizations.ts @@ -0,0 +1,50 @@ +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'; +import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js'; +import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; +import { buildGetTotalRowCountWithPool } from '#src/database/row-count.js'; +import { buildUpdateWhereWithPool } from '#src/database/update-where.js'; +import TenantQueries from '#src/utils/TenantQueries.js'; + +export default class OrganizationQueries extends TenantQueries { + #findTotalNumber = buildGetTotalRowCountWithPool(this.pool, Organizations.table); + #findAll = buildFindAllEntitiesWithPool(this.pool)(Organizations); + #findById = buildFindEntityByIdWithPool(this.pool)(Organizations); + #insert = buildInsertIntoWithPool(this.pool)(Organizations, { returning: true }); + #updateById = buildUpdateWhereWithPool(this.pool)(Organizations, true); + #deleteById = buildDeleteByIdWithPool(this.pool, Organizations.table); + + async findTotalNumber(): Promise { + const { count } = await this.#findTotalNumber(); + return count; + } + + async findAll(limit: number, offset: number): Promise { + return this.#findAll(limit, offset); + } + + async findById(id: string): Promise> { + return this.#findById(id); + } + + async insert( + data: Omit, 'id'> + ): Promise> { + return this.#insert({ id: generateStandardId(), ...data }); + } + + async updateById( + id: string, + data: Partial>, + jsonbMode: 'replace' | 'merge' = 'replace' + ): Promise> { + return this.#updateById({ set: data, where: { id }, jsonbMode }); + } + + async deleteById(id: string): Promise { + await this.#deleteById(id); + } +} diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index 2c2a03e65..730ea2589 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -25,6 +25,7 @@ import hookRoutes from './hook.js'; import interactionRoutes from './interaction/index.js'; import logRoutes from './log.js'; import logtoConfigRoutes from './logto-config.js'; +import organizationRoutes from './organizations.js'; import resourceRoutes from './resource.js'; import roleRoutes from './role.js'; import roleScopeRoutes from './role.scope.js'; @@ -63,6 +64,7 @@ const createRouters = (tenant: TenantContext) => { verificationCodeRoutes(managementRouter, tenant); userAssetsRoutes(managementRouter, tenant); domainRoutes(managementRouter, tenant); + organizationRoutes(managementRouter, tenant); const anonymousRouter: AnonymousRouter = new Router(); wellKnownRoutes(anonymousRouter, tenant); diff --git a/packages/core/src/routes/organizations.ts b/packages/core/src/routes/organizations.ts new file mode 100644 index 000000000..343199196 --- /dev/null +++ b/packages/core/src/routes/organizations.ts @@ -0,0 +1,53 @@ +import { type CreateOrganization, type Organization, Organizations } from '@logto/schemas'; +import { type OmitAutoSetFields } from '@logto/shared'; + +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, + { + queries: { organizations }, + }, + ]: RouterInitArgs +) { + const router = new SchemaRouter(Organizations, new OrganizationActions(organizations)); + + originalRouter.use(router.routes()); +} diff --git a/packages/core/src/routes/types.ts b/packages/core/src/routes/types.ts index 3beb572b6..0550f4f99 100644 --- a/packages/core/src/routes/types.ts +++ b/packages/core/src/routes/types.ts @@ -8,10 +8,9 @@ import type TenantContext from '#src/tenants/TenantContext.js'; export type AnonymousRouter = Router; -export type AuthedRouter = Router< - unknown, - WithAuthContext & WithLogContext & WithI18nContext & ExtendableContext ->; +type AuthedRouterContext = WithAuthContext & WithLogContext & WithI18nContext & ExtendableContext; + +export type AuthedRouter = Router; type RouterInit = (router: T, tenant: TenantContext) => void; export type RouterInitArgs = Parameters>; diff --git a/packages/core/src/tenants/Queries.ts b/packages/core/src/tenants/Queries.ts index 49c5cb118..577c4053c 100644 --- a/packages/core/src/tenants/Queries.ts +++ b/packages/core/src/tenants/Queries.ts @@ -11,6 +11,7 @@ import { createHooksQueries } from '#src/queries/hooks.js'; import { createLogQueries } from '#src/queries/log.js'; import { createLogtoConfigQueries } from '#src/queries/logto-config.js'; import { createOidcModelInstanceQueries } from '#src/queries/oidc-model-instance.js'; +import OrganizationQueries from '#src/queries/organizations.js'; import { createPasscodeQueries } from '#src/queries/passcode.js'; import { createResourceQueries } from '#src/queries/resource.js'; import { createRolesScopesQueries } from '#src/queries/roles-scopes.js'; @@ -41,6 +42,7 @@ export default class Queries { hooks = createHooksQueries(this.pool); domains = createDomainsQueries(this.pool); dailyActiveUsers = createDailyActiveUsersQueries(this.pool); + organizations = new OrganizationQueries(this.pool); constructor( public readonly pool: CommonQueryMethods, diff --git a/packages/core/src/utils/SchemaRouter.test.ts b/packages/core/src/utils/SchemaRouter.test.ts new file mode 100644 index 000000000..5b0cf0386 --- /dev/null +++ b/packages/core/src/utils/SchemaRouter.test.ts @@ -0,0 +1,122 @@ +import { type GeneratedSchema } from '@logto/schemas'; +import { z } from 'zod'; + +import RequestError from '#src/errors/RequestError/index.js'; + +import SchemaRouter, { type SchemaActions } from './SchemaRouter.js'; +import { createRequester } from './test-utils.js'; + +const { jest } = import.meta; + +type CreateSchema = { + id?: string; +}; + +type Schema = { + id: string; +}; + +describe('SchemaRouter', () => { + const schema: GeneratedSchema = { + table: 'test_table', + tableSingular: 'test_table', + fields: { + id: 'id', + }, + fieldKeys: ['id'], + createGuard: z.object({ id: z.string().optional() }), + guard: z.object({ id: z.string() }), + }; + const entities = [{ id: 'test' }, { id: 'test2' }] as const satisfies readonly Schema[]; + const actions: SchemaActions = { + get: jest.fn().mockResolvedValue([entities.length, entities]), + getById: jest.fn(async (id) => { + const entity = entities.find((entity) => entity.id === id); + if (!entity) { + throw new RequestError({ code: 'entity.not_found', status: 404 }); + } + return entity; + }), + postGuard: z.object({ id: z.string().optional() }), + post: jest.fn(async () => ({ id: 'test_new' })), + patchGuard: z.object({ id: z.string().optional() }), + patchById: jest.fn(async (id, data) => ({ id, ...data })), + deleteById: jest.fn(), + }; + const schemaRouter = new SchemaRouter(schema, actions); + const request = createRequester({ authedRoutes: (router) => router.use(schemaRouter.routes()) }); + const baseRoute = `/${schema.table.replaceAll('_', '-')}`; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('get', () => { + it('should be able to get all entities', async () => { + const response = await request.get(baseRoute); + + expect(actions.get).toHaveBeenCalledWith( + expect.objectContaining({ disabled: false, offset: 0, limit: 20 }) + ); + expect(response.body).toStrictEqual(entities); + }); + + it('should be able to get all entities with pagination', async () => { + const response = await request.get(`${baseRoute}?page=1&page_size=10`); + + expect(actions.get).toHaveBeenCalledWith(expect.objectContaining({ offset: 0, limit: 10 })); + expect(response.body).toStrictEqual(entities); + expect(response.header).toHaveProperty('total-number', '2'); + }); + }); + + describe('post', () => { + it('should be able to create an entity', async () => { + const response = await request.post(baseRoute).send({}); + + expect(actions.post).toHaveBeenCalledWith({}); + expect(response.body).toStrictEqual({ id: 'test_new' }); + }); + + it('should throw with invalid input body', async () => { + const response = await request.post(baseRoute).send({ id: 1 }); + + expect(response.status).toEqual(400); + }); + }); + + describe('getById', () => { + it('should be able to get an entity by id', async () => { + const response = await request.get(`${baseRoute}/test`); + + expect(actions.getById).toHaveBeenCalledWith('test'); + expect(response.body).toStrictEqual(entities[0]); + }); + + // This test case is actually nice-to-have. It's not required for the router to work. + it('should throw with invalid id', async () => { + const response = await request.get(`${baseRoute}/2`); + + expect(response.status).toEqual(404); + }); + }); + + describe('patchById', () => { + 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).toHaveBeenCalledWith('test', { id: 'test_new' }); + expect(response.body).toStrictEqual({ id: 'test_new' }); + expect(response.status).toEqual(200); + }); + }); + + describe('deleteById', () => { + it('should be able to delete an entity by id', async () => { + const response = await request.delete(`${baseRoute}/test`); + + expect(actions.deleteById).toHaveBeenCalledWith('test'); + expect(response.status).toEqual(204); + }); + }); +}); diff --git a/packages/core/src/utils/SchemaRouter.ts b/packages/core/src/utils/SchemaRouter.ts new file mode 100644 index 000000000..fc867e69f --- /dev/null +++ b/packages/core/src/utils/SchemaRouter.ts @@ -0,0 +1,173 @@ +import { type SchemaLike, type GeneratedSchema } from '@logto/schemas'; +import Router, { type IRouterParamContext } from 'koa-router'; +import { z } from 'zod'; + +import koaGuard from '#src/middleware/koa-guard.js'; +import koaPagination, { type Pagination } from '#src/middleware/koa-pagination.js'; + +/** + * Actions configuration for a {@link SchemaRouter}. It contains the + * necessary functions to handle the CRUD operations for a schema. + */ +export abstract class SchemaActions< + CreateSchema extends SchemaLike, + Schema extends CreateSchema, + 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`. 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. + */ + 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. + */ + 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. + */ + public abstract post(data: PostSchema): Promise>; + + /** + * 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. + */ + 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. + */ + public abstract deleteById(id: string): Promise; +} + +/** + * A standard RESTful router for a schema. + * + * It provides the following routes by configuring the `actions`: + * + * - `GET /`: Get a list of entities. + * - `POST /`: Create an entity. + * - `GET /:id`: Get an entity by ID. + * - `PATCH /:id`: Update an entity by ID. + * - `DELETE /:id`: Delete an entity by ID. + * + * Browse the source code for more details about request/response validation. + * + * @see {@link SchemaActions} for the `actions` configuration. + */ +export default class SchemaRouter< + CreateSchema extends SchemaLike, + Schema extends CreateSchema, + 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 + ) { + super({ prefix: '/' + schema.table.replaceAll('_', '-') }); + + this.get( + '/', + koaPagination(), + koaGuard({ response: schema.guard.array(), status: [200] }), + async (ctx, next) => { + const [count, entities] = await actions.get(ctx.pagination); + ctx.pagination.totalCount = count; + ctx.body = entities; + return next(); + } + ); + + this.post( + '/', + koaGuard({ + body: actions.postGuard, + response: schema.guard, + status: [201, 422], + }), + async (ctx, next) => { + ctx.body = await actions.post(ctx.guard.body); + ctx.status = 201; + return next(); + } + ); + + this.get( + '/:id', + koaGuard({ + params: z.object({ id: z.string().min(1) }), + response: schema.guard, + status: [200, 404], + }), + async (ctx, next) => { + ctx.body = await actions.getById(ctx.guard.params.id); + return next(); + } + ); + + this.patch( + '/:id', + koaGuard({ + params: z.object({ id: z.string().min(1) }), + body: actions.patchGuard, + response: schema.guard, + status: [200, 404], + }), + async (ctx, next) => { + ctx.body = await actions.patchById(ctx.guard.params.id, ctx.guard.body); + return next(); + } + ); + + this.delete( + '/:id', + koaGuard({ + params: z.object({ id: z.string().min(1) }), + status: [204, 404], + }), + async (ctx, next) => { + await actions.deleteById(ctx.guard.params.id); + ctx.status = 204; + return next(); + } + ); + } +} diff --git a/packages/core/src/utils/TenantQueries.ts b/packages/core/src/utils/TenantQueries.ts new file mode 100644 index 000000000..88c023442 --- /dev/null +++ b/packages/core/src/utils/TenantQueries.ts @@ -0,0 +1,5 @@ +import { type CommonQueryMethods } from 'slonik'; + +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); + }); +});