From bb22ce3accb0249ac6729719f2a5ac379fa5c16d Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Mon, 9 Oct 2023 17:15:46 +0800 Subject: [PATCH 1/4] feat(core): org basic apis --- packages/core/src/database/delete-by-id.ts | 15 +++ packages/core/src/database/row-count.ts | 12 +++ packages/core/src/middleware/koa-guard.ts | 10 +- .../core/src/middleware/koa-pagination.ts | 2 +- packages/core/src/queries/organizations.ts | 48 +++++++++ packages/core/src/routes/init.ts | 2 + packages/core/src/routes/organizations.ts | 26 +++++ packages/core/src/routes/types.ts | 7 +- packages/core/src/tenants/Queries.ts | 2 + packages/core/src/utils/SchemaRouter.ts | 100 ++++++++++++++++++ packages/core/src/utils/TenantQueries.ts | 5 + 11 files changed, 219 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/database/delete-by-id.ts create mode 100644 packages/core/src/queries/organizations.ts create mode 100644 packages/core/src/routes/organizations.ts create mode 100644 packages/core/src/utils/SchemaRouter.ts create mode 100644 packages/core/src/utils/TenantQueries.ts 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..fb55d2b36 --- /dev/null +++ b/packages/core/src/queries/organizations.ts @@ -0,0 +1,48 @@ +import { type Organization, Organizations } from '@logto/schemas'; +import { 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: OmitAutoSetFields): Promise> { + return this.#insert(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..9d691ded8 --- /dev/null +++ b/packages/core/src/routes/organizations.ts @@ -0,0 +1,26 @@ +import { Organizations } from '@logto/schemas'; + +import SchemaRouter from '#src/utils/SchemaRouter.js'; + +import { type AuthedRouter, type RouterInitArgs } from './types.js'; + +export default function organizationRoutes( + ...[ + originalRouter, + { + queries: { organizations }, + }, + ]: RouterInitArgs +) { + const router = new SchemaRouter(Organizations, { + findAll: async ({ limit, offset }) => + Promise.all([organizations.findTotalNumber(), organizations.findAll(limit, offset)]), + findById: organizations.findById, + insert: organizations.insert, + updateGuard: Organizations.guard.omit({ id: true, createdAt: true }).partial(), + updateById: organizations.updateById, + deleteById: organizations.deleteById, + }); + + 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.ts b/packages/core/src/utils/SchemaRouter.ts new file mode 100644 index 000000000..7413cc40a --- /dev/null +++ b/packages/core/src/utils/SchemaRouter.ts @@ -0,0 +1,100 @@ +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'; + +type SchemaActions< + CreateSchema extends SchemaLike, + Schema extends CreateSchema, + UpdateSchema extends Partial, +> = { + findAll: (pagination: Pagination) => Promise<[count: number, entities: readonly Schema[]]>; + findById: (id: string) => Promise>; + insert: (data: CreateSchema) => Promise>; + updateGuard: z.ZodType; + updateById: (id: string, data: UpdateSchema) => Promise>; + deleteById: (id: string) => Promise; +}; + +export default class SchemaRouter< + CreateSchema extends SchemaLike, + Schema extends CreateSchema, + UpdateSchema 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({ isOptional: true }), + koaGuard({ response: schema.guard.array(), status: [200] }), + async (ctx, next) => { + const [count, entities] = await actions.findAll(ctx.pagination); + ctx.pagination.totalCount = count; + ctx.body = entities; + return next(); + } + ); + + this.post( + '/', + koaGuard({ + body: schema.createGuard, + response: schema.guard, + status: [201, 422], + }), + async (ctx, next) => { + ctx.body = await actions.insert(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.findById(ctx.guard.params.id); + return next(); + } + ); + + this.patch( + '/:id', + koaGuard({ + params: z.object({ id: z.string().min(1) }), + body: actions.updateGuard, + response: schema.guard, + status: [200, 404], + }), + async (ctx, next) => { + void actions.updateById(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..b4e5b0c8e --- /dev/null +++ b/packages/core/src/utils/TenantQueries.ts @@ -0,0 +1,5 @@ +import { type CommonQueryMethods } from 'slonik'; + +export default abstract class TenantQueries { + constructor(public readonly pool: CommonQueryMethods) {} +} From 8e931d757f28103b5930b03b177df86dbd90edeb Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Mon, 9 Oct 2023 18:18:38 +0800 Subject: [PATCH 2/4] refactor(core): add unit tests for SchemaRouter --- packages/core/src/routes/organizations.ts | 12 +- packages/core/src/utils/SchemaRouter.test.ts | 121 +++++++++++++++++++ packages/core/src/utils/SchemaRouter.ts | 82 +++++++++++-- 3 files changed, 199 insertions(+), 16 deletions(-) create mode 100644 packages/core/src/utils/SchemaRouter.test.ts diff --git a/packages/core/src/routes/organizations.ts b/packages/core/src/routes/organizations.ts index 9d691ded8..3f3a1933f 100644 --- a/packages/core/src/routes/organizations.ts +++ b/packages/core/src/routes/organizations.ts @@ -13,12 +13,14 @@ export default function organizationRoutes( ]: RouterInitArgs ) { const router = new SchemaRouter(Organizations, { - findAll: async ({ limit, offset }) => + get: async ({ limit, offset }) => Promise.all([organizations.findTotalNumber(), organizations.findAll(limit, offset)]), - findById: organizations.findById, - insert: organizations.insert, - updateGuard: Organizations.guard.omit({ id: true, createdAt: true }).partial(), - updateById: organizations.updateById, + getById: organizations.findById, + post: organizations.insert, + patchById: { + guard: Organizations.guard.omit({ id: true, createdAt: true }).partial(), + run: organizations.updateById, + }, deleteById: organizations.deleteById, }); diff --git a/packages/core/src/utils/SchemaRouter.test.ts b/packages/core/src/utils/SchemaRouter.test.ts new file mode 100644 index 000000000..cd4381a17 --- /dev/null +++ b/packages/core/src/utils/SchemaRouter.test.ts @@ -0,0 +1,121 @@ +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; + }), + post: jest.fn(async () => ({ id: 'test_new' })), + patchById: { + guard: z.object({ id: z.string().optional() }), + run: 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: true })); + 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.run).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 index 7413cc40a..f1b3e193d 100644 --- a/packages/core/src/utils/SchemaRouter.ts +++ b/packages/core/src/utils/SchemaRouter.ts @@ -5,19 +5,79 @@ import { z } from 'zod'; import koaGuard from '#src/middleware/koa-guard.js'; import koaPagination, { type Pagination } from '#src/middleware/koa-pagination.js'; -type SchemaActions< +/** + * Actions configuration for a {@link SchemaRouter}. + */ +export type SchemaActions< CreateSchema extends SchemaLike, Schema extends CreateSchema, UpdateSchema extends Partial, > = { - findAll: (pagination: Pagination) => Promise<[count: number, entities: readonly Schema[]]>; - findById: (id: string) => Promise>; - insert: (data: CreateSchema) => Promise>; - updateGuard: z.ZodType; - updateById: (id: string, data: UpdateSchema) => Promise>; + /** + * 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. + * @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[]]>; + /** + * 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>; + /** + * 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>; + /** + * 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; + /** + * 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>; + }; + /** + * 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; }; +/** + * 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, @@ -36,7 +96,7 @@ export default class SchemaRouter< koaPagination({ isOptional: true }), koaGuard({ response: schema.guard.array(), status: [200] }), async (ctx, next) => { - const [count, entities] = await actions.findAll(ctx.pagination); + const [count, entities] = await actions.get(ctx.pagination); ctx.pagination.totalCount = count; ctx.body = entities; return next(); @@ -51,7 +111,7 @@ export default class SchemaRouter< status: [201, 422], }), async (ctx, next) => { - ctx.body = await actions.insert(ctx.guard.body); + ctx.body = await actions.post(ctx.guard.body); ctx.status = 201; return next(); } @@ -65,7 +125,7 @@ export default class SchemaRouter< status: [200, 404], }), async (ctx, next) => { - ctx.body = await actions.findById(ctx.guard.params.id); + ctx.body = await actions.getById(ctx.guard.params.id); return next(); } ); @@ -74,12 +134,12 @@ export default class SchemaRouter< '/:id', koaGuard({ params: z.object({ id: z.string().min(1) }), - body: actions.updateGuard, + body: actions.patchById.guard, response: schema.guard, status: [200, 404], }), async (ctx, next) => { - void actions.updateById(ctx.guard.params.id, ctx.guard.body); + ctx.body = await actions.patchById.run(ctx.guard.params.id, ctx.guard.body); return next(); } ); From 4110409bd1e6b394d3dcdae134139abfe46596ca Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Wed, 11 Oct 2023 12:48:20 +0800 Subject: [PATCH 3/4] refactor(core): organization crud apis --- packages/core/src/queries/organizations.ts | 10 ++- .../core/src/routes/organization-roles.ts | 1 + packages/core/src/routes/organizations.ts | 51 +++++++++--- packages/core/src/utils/SchemaRouter.test.ts | 15 ++-- packages/core/src/utils/SchemaRouter.ts | 81 +++++++++++-------- packages/core/src/utils/TenantQueries.ts | 2 +- .../integration-tests/src/api/organization.ts | 37 +++++++++ .../src/tests/api/organization.test.ts | 81 +++++++++++++++++++ 8 files changed, 219 insertions(+), 59 deletions(-) create mode 100644 packages/core/src/routes/organization-roles.ts create mode 100644 packages/integration-tests/src/api/organization.ts create mode 100644 packages/integration-tests/src/tests/api/organization.test.ts 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); + }); +}); From e02bf4c98735c08ea2949878d871ae00ed660f1b Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Wed, 11 Oct 2023 18:41:26 +0800 Subject: [PATCH 4/4] chore: remove unused file --- packages/core/src/routes/organization-roles.ts | 1 - 1 file changed, 1 deletion(-) delete mode 100644 packages/core/src/routes/organization-roles.ts diff --git a/packages/core/src/routes/organization-roles.ts b/packages/core/src/routes/organization-roles.ts deleted file mode 100644 index 9f0a57053..000000000 --- a/packages/core/src/routes/organization-roles.ts +++ /dev/null @@ -1 +0,0 @@ -function organizationRoleRoutes() {}