mirror of
https://github.com/logto-io/logto.git
synced 2025-01-13 21:30:30 -05:00
Merge pull request #4620 from logto-io/gao-org-apis-1
feat(core): org basic apis
This commit is contained in:
commit
c3b9e57fe3
14 changed files with 561 additions and 10 deletions
15
packages/core/src/database/delete-by-id.ts
Normal file
15
packages/core/src/database/delete-by-id.ts
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
|
@ -1,6 +1,18 @@
|
||||||
import type { CommonQueryMethods, IdentifierSqlToken } from 'slonik';
|
import type { CommonQueryMethods, IdentifierSqlToken } from 'slonik';
|
||||||
import { sql } 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 =
|
export const getTotalRowCountWithPool =
|
||||||
(pool: CommonQueryMethods) => async (table: IdentifierSqlToken) => {
|
(pool: CommonQueryMethods) => async (table: IdentifierSqlToken) => {
|
||||||
// Postgres returns a biging for count(*), which is then converted to a string by query library.
|
// Postgres returns a biging for count(*), which is then converted to a string by query library.
|
||||||
|
|
|
@ -23,7 +23,7 @@ export type GuardConfig<QueryT, BodyT, ParametersT, ResponseT, FilesT> = {
|
||||||
* // e.g. parse '?key1=foo'
|
* // e.g. parse '?key1=foo'
|
||||||
* z.object({ key1: z.string() })
|
* z.object({ key1: z.string() })
|
||||||
*/
|
*/
|
||||||
query?: ZodType<QueryT>;
|
query?: ZodType<QueryT, ZodTypeDef, unknown>;
|
||||||
/**
|
/**
|
||||||
* Guard JSON request body. You can treat the body like a normal object.
|
* Guard JSON request body. You can treat the body like a normal object.
|
||||||
*
|
*
|
||||||
|
@ -33,7 +33,7 @@ export type GuardConfig<QueryT, BodyT, ParametersT, ResponseT, FilesT> = {
|
||||||
* key2: z.object({ key3: z.number() }).array(),
|
* key2: z.object({ key3: z.number() }).array(),
|
||||||
* })
|
* })
|
||||||
*/
|
*/
|
||||||
body?: ZodType<BodyT>;
|
body?: ZodType<BodyT, ZodTypeDef, unknown>;
|
||||||
/**
|
/**
|
||||||
* Guard `koa-router` path parameters (i.e. `ctx.params`).
|
* Guard `koa-router` path parameters (i.e. `ctx.params`).
|
||||||
*
|
*
|
||||||
|
@ -41,19 +41,19 @@ export type GuardConfig<QueryT, BodyT, ParametersT, ResponseT, FilesT> = {
|
||||||
* // e.g. parse '/foo/:key1'
|
* // e.g. parse '/foo/:key1'
|
||||||
* z.object({ key1: z.string() })
|
* z.object({ key1: z.string() })
|
||||||
*/
|
*/
|
||||||
params?: ZodType<ParametersT>;
|
params?: ZodType<ParametersT, ZodTypeDef, unknown>;
|
||||||
/**
|
/**
|
||||||
* Guard response body.
|
* Guard response body.
|
||||||
*
|
*
|
||||||
* @example z.object({ key1: z.string() })
|
* @example z.object({ key1: z.string() })
|
||||||
*/
|
*/
|
||||||
response?: ZodType<ResponseT>;
|
response?: ZodType<ResponseT, ZodTypeDef, unknown>;
|
||||||
/**
|
/**
|
||||||
* Guard response status code. It produces a `ServerError` (500)
|
* Guard response status code. It produces a `ServerError` (500)
|
||||||
* if the response does not satisfy any of the given value(s).
|
* if the response does not satisfy any of the given value(s).
|
||||||
*/
|
*/
|
||||||
status?: number | number[];
|
status?: number | number[];
|
||||||
files?: ZodType<FilesT>;
|
files?: ZodType<FilesT, ZodTypeDef, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GuardedRequest<QueryT, BodyT, ParametersT, FilesT> = {
|
export type GuardedRequest<QueryT, BodyT, ParametersT, FilesT> = {
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { number } from 'zod';
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import { buildLink } from '#src/utils/pagination.js';
|
import { buildLink } from '#src/utils/pagination.js';
|
||||||
|
|
||||||
type Pagination = {
|
export type Pagination = {
|
||||||
offset: number;
|
offset: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
totalCount?: number;
|
totalCount?: number;
|
||||||
|
|
50
packages/core/src/queries/organizations.ts
Normal file
50
packages/core/src/queries/organizations.ts
Normal file
|
@ -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<number> {
|
||||||
|
const { count } = await this.#findTotalNumber();
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(limit: number, offset: number): Promise<readonly Organization[]> {
|
||||||
|
return this.#findAll(limit, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Readonly<Organization>> {
|
||||||
|
return this.#findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async insert(
|
||||||
|
data: Omit<OmitAutoSetFields<CreateOrganization>, 'id'>
|
||||||
|
): Promise<Readonly<Organization>> {
|
||||||
|
return this.#insert({ id: generateStandardId(), ...data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateById(
|
||||||
|
id: string,
|
||||||
|
data: Partial<OmitAutoSetFields<Organization>>,
|
||||||
|
jsonbMode: 'replace' | 'merge' = 'replace'
|
||||||
|
): Promise<Readonly<Organization>> {
|
||||||
|
return this.#updateById({ set: data, where: { id }, jsonbMode });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteById(id: string): Promise<void> {
|
||||||
|
await this.#deleteById(id);
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,6 +25,7 @@ import hookRoutes from './hook.js';
|
||||||
import interactionRoutes from './interaction/index.js';
|
import interactionRoutes from './interaction/index.js';
|
||||||
import logRoutes from './log.js';
|
import logRoutes from './log.js';
|
||||||
import logtoConfigRoutes from './logto-config.js';
|
import logtoConfigRoutes from './logto-config.js';
|
||||||
|
import organizationRoutes from './organizations.js';
|
||||||
import resourceRoutes from './resource.js';
|
import resourceRoutes from './resource.js';
|
||||||
import roleRoutes from './role.js';
|
import roleRoutes from './role.js';
|
||||||
import roleScopeRoutes from './role.scope.js';
|
import roleScopeRoutes from './role.scope.js';
|
||||||
|
@ -63,6 +64,7 @@ const createRouters = (tenant: TenantContext) => {
|
||||||
verificationCodeRoutes(managementRouter, tenant);
|
verificationCodeRoutes(managementRouter, tenant);
|
||||||
userAssetsRoutes(managementRouter, tenant);
|
userAssetsRoutes(managementRouter, tenant);
|
||||||
domainRoutes(managementRouter, tenant);
|
domainRoutes(managementRouter, tenant);
|
||||||
|
organizationRoutes(managementRouter, tenant);
|
||||||
|
|
||||||
const anonymousRouter: AnonymousRouter = new Router();
|
const anonymousRouter: AnonymousRouter = new Router();
|
||||||
wellKnownRoutes(anonymousRouter, tenant);
|
wellKnownRoutes(anonymousRouter, tenant);
|
||||||
|
|
53
packages/core/src/routes/organizations.ts
Normal file
53
packages/core/src/routes/organizations.ts
Normal file
|
@ -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<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,
|
||||||
|
{
|
||||||
|
queries: { organizations },
|
||||||
|
},
|
||||||
|
]: RouterInitArgs<T>
|
||||||
|
) {
|
||||||
|
const router = new SchemaRouter(Organizations, new OrganizationActions(organizations));
|
||||||
|
|
||||||
|
originalRouter.use(router.routes());
|
||||||
|
}
|
|
@ -8,10 +8,9 @@ import type TenantContext from '#src/tenants/TenantContext.js';
|
||||||
|
|
||||||
export type AnonymousRouter = Router<unknown, WithLogContext & WithI18nContext>;
|
export type AnonymousRouter = Router<unknown, WithLogContext & WithI18nContext>;
|
||||||
|
|
||||||
export type AuthedRouter = Router<
|
type AuthedRouterContext = WithAuthContext & WithLogContext & WithI18nContext & ExtendableContext;
|
||||||
unknown,
|
|
||||||
WithAuthContext & WithLogContext & WithI18nContext & ExtendableContext
|
export type AuthedRouter = Router<unknown, AuthedRouterContext>;
|
||||||
>;
|
|
||||||
|
|
||||||
type RouterInit<T> = (router: T, tenant: TenantContext) => void;
|
type RouterInit<T> = (router: T, tenant: TenantContext) => void;
|
||||||
export type RouterInitArgs<T> = Parameters<RouterInit<T>>;
|
export type RouterInitArgs<T> = Parameters<RouterInit<T>>;
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { createHooksQueries } from '#src/queries/hooks.js';
|
||||||
import { createLogQueries } from '#src/queries/log.js';
|
import { createLogQueries } from '#src/queries/log.js';
|
||||||
import { createLogtoConfigQueries } from '#src/queries/logto-config.js';
|
import { createLogtoConfigQueries } from '#src/queries/logto-config.js';
|
||||||
import { createOidcModelInstanceQueries } from '#src/queries/oidc-model-instance.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 { createPasscodeQueries } from '#src/queries/passcode.js';
|
||||||
import { createResourceQueries } from '#src/queries/resource.js';
|
import { createResourceQueries } from '#src/queries/resource.js';
|
||||||
import { createRolesScopesQueries } from '#src/queries/roles-scopes.js';
|
import { createRolesScopesQueries } from '#src/queries/roles-scopes.js';
|
||||||
|
@ -41,6 +42,7 @@ export default class Queries {
|
||||||
hooks = createHooksQueries(this.pool);
|
hooks = createHooksQueries(this.pool);
|
||||||
domains = createDomainsQueries(this.pool);
|
domains = createDomainsQueries(this.pool);
|
||||||
dailyActiveUsers = createDailyActiveUsersQueries(this.pool);
|
dailyActiveUsers = createDailyActiveUsersQueries(this.pool);
|
||||||
|
organizations = new OrganizationQueries(this.pool);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly pool: CommonQueryMethods,
|
public readonly pool: CommonQueryMethods,
|
||||||
|
|
122
packages/core/src/utils/SchemaRouter.test.ts
Normal file
122
packages/core/src/utils/SchemaRouter.test.ts
Normal file
|
@ -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<CreateSchema, Schema> = {
|
||||||
|
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<CreateSchema, Schema, CreateSchema, CreateSchema> = {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
173
packages/core/src/utils/SchemaRouter.ts
Normal file
173
packages/core/src/utils/SchemaRouter.ts
Normal file
|
@ -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<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`. 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<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.
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
public abstract post(data: PostSchema): Promise<Readonly<Schema>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Readonly<Schema>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<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, PostSchema, PatchSchema>
|
||||||
|
) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
5
packages/core/src/utils/TenantQueries.ts
Normal file
5
packages/core/src/utils/TenantQueries.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import { type CommonQueryMethods } from 'slonik';
|
||||||
|
|
||||||
|
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…
Add table
Reference in a new issue