0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

Merge pull request #4658 from logto-io/gao-add-relation-routes

refactor(core): `addRelationRoutes()` for `SchemaRouter`
This commit is contained in:
Gao Sun 2023-10-16 02:16:41 -05:00 committed by GitHub
commit 33e9a5d695
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 283 additions and 76 deletions

View file

@ -44,6 +44,7 @@
"@silverhand/essentials": "^2.8.4",
"@simplewebauthn/server": "^8.2.0",
"@withtyped/client": "^0.7.22",
"camelcase": "^8.0.0",
"chalk": "^5.0.0",
"clean-deep": "^3.4.0",
"date-fns": "^2.29.3",
@ -73,7 +74,6 @@
"otplib": "^12.0.1",
"p-retry": "^6.0.0",
"pg-protocol": "^1.6.0",
"pluralize": "^8.0.0",
"qrcode": "^1.5.3",
"redis": "^4.6.5",
"roarr": "^7.11.0",
@ -101,7 +101,6 @@
"@types/koa__cors": "^4.0.0",
"@types/node": "^18.11.18",
"@types/oidc-provider": "^8.0.0",
"@types/pluralize": "^0.0.31",
"@types/qrcode": "^1.5.2",
"@types/semver": "^7.3.12",
"@types/sinon": "^10.0.13",

View file

@ -6,6 +6,7 @@ import {
OrganizationRoles,
OrganizationScopes,
OrganizationRoleScopeRelations,
Users,
} from '@logto/schemas';
import { type CommonQueryMethods } from 'slonik';
@ -31,6 +32,8 @@ export default class OrganizationQueries extends SchemaQueries<
OrganizationRoles,
OrganizationScopes
),
/** Queries for organization - user relations. */
users: new RelationQueries(this.pool, 'organization_user_relations', Organizations, Users),
};
constructor(pool: CommonQueryMethods) {

View file

@ -51,7 +51,9 @@ export default function organizationRoleRoutes<T extends AuthedRouter>(
const router = new SchemaRouter(OrganizationRoles, actions, { disabled: { post: true } });
/** Allows to carry an initial set of scopes for creating a new organization role. */
type CreateOrganizationRolePayload = Omit<CreateOrganizationRole, 'id'> & { scopeIds: string[] };
type CreateOrganizationRolePayload = Omit<CreateOrganizationRole, 'id'> & {
organizationScopeIds: string[];
};
const createGuard: z.ZodType<CreateOrganizationRolePayload, z.ZodTypeDef, unknown> =
OrganizationRoles.createGuard
@ -59,7 +61,7 @@ export default function organizationRoleRoutes<T extends AuthedRouter>(
id: true,
})
.extend({
scopeIds: z.array(z.string()).default([]),
organizationScopeIds: z.array(z.string()).default([]),
});
router.post(
@ -70,7 +72,7 @@ export default function organizationRoleRoutes<T extends AuthedRouter>(
status: [201, 422],
}),
async (ctx, next) => {
const { scopeIds, ...data } = ctx.guard.body;
const { organizationScopeIds: scopeIds, ...data } = ctx.guard.body;
const role = await actions.post(data);
if (scopeIds.length > 0) {
@ -83,63 +85,7 @@ export default function organizationRoleRoutes<T extends AuthedRouter>(
}
);
// MARK: Role - scope relations routes
router.get(
'/:id/scopes',
koaGuard({
params: z.object({ id: z.string().min(1) }),
response: OrganizationScopes.guard.array(),
status: [200, 404],
}),
async (ctx, next) => {
const { id } = ctx.guard.params;
// Ensure that role exists
await actions.getById(id);
ctx.body = await rolesScopes.getEntries(OrganizationScopes, { organizationRoleId: id });
return next();
}
);
router.post(
'/:id/scopes',
koaGuard({
params: z.object({ id: z.string().min(1) }),
body: z.object({ scopeIds: z.string().min(1).array().nonempty() }),
response: OrganizationScopes.guard.array(),
status: [200, 404, 422],
}),
async (ctx, next) => {
const {
params: { id },
body: { scopeIds },
} = ctx.guard;
await rolesScopes.insert(...scopeIds.map<[string, string]>((scopeId) => [id, scopeId]));
ctx.body = await rolesScopes.getEntries(OrganizationScopes, { organizationRoleId: id });
return next();
}
);
router.delete(
'/:id/scopes/:scopeId',
koaGuard({
params: z.object({ id: z.string().min(1), scopeId: z.string().min(1) }),
status: [204, 422],
}),
async (ctx, next) => {
const {
params: { id, scopeId },
} = ctx.guard;
await rolesScopes.delete({ organizationRoleId: id, organizationScopeId: scopeId });
ctx.status = 204;
return next();
}
);
router.addRelationRoutes(OrganizationScopes, rolesScopes, 'scopes');
originalRouter.use(router.routes());
}

View file

@ -1,6 +1,7 @@
import { type SchemaLike, type GeneratedSchema, type Guard } from '@logto/schemas';
import { generateStandardId, type OmitAutoSetFields } from '@logto/shared';
import { type DeepPartial } from '@silverhand/essentials';
import camelcase from 'camelcase';
import deepmerge from 'deepmerge';
import Router, { type IRouterParamContext } from 'koa-router';
import { z } from 'zod';
@ -8,8 +9,31 @@ import { z } from 'zod';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination, { type Pagination } from '#src/middleware/koa-pagination.js';
import type RelationQueries from './RelationQueries.js';
import type SchemaQueries from './SchemaQueries.js';
/**
* Generate the pathname for from a table name.
*
* @example
* ```ts
* tableToPathname('organization_role') // => 'organization-role'
* ```
*/
const tableToPathname = (tableName: string) => tableName.replaceAll('_', '-');
/**
* Generate the camel case schema ID column name.
*
* @example
* ```ts
* camelCaseSchemaId({ tableSingular: 'organization' as const }) // => 'organizationId'
* ```
*
*/
const camelCaseSchemaId = <T extends { tableSingular: Table }, Table extends string>(schema: T) =>
`${camelcase(schema.tableSingular)}Id` as const;
/**
* Actions configuration for a {@link SchemaRouter}. It contains the
* necessary functions to handle the CRUD operations for a schema.
@ -126,7 +150,7 @@ export default class SchemaRouter<
public readonly actions: SchemaActions<Key, CreateSchema, Schema>,
config: DeepPartial<SchemaRouterConfig> = {}
) {
super({ prefix: '/' + schema.table.replaceAll('_', '-') });
super({ prefix: '/' + tableToPathname(schema.table) });
this.config = deepmerge<SchemaRouterConfig, DeepPartial<SchemaRouterConfig>>(
{
@ -220,4 +244,113 @@ export default class SchemaRouter<
);
}
}
/**
* Add routes for relations between the current schema and another schema.
*
* The routes are:
*
* - `GET /:id/[pathname]`: Get the entities of the relation.
* - `POST /:id/[pathname]`: Add entities to the relation.
* - `DELETE /:id/[pathname]/:relationSchemaId`: Remove an entity from the relation set.
* The `:relationSchemaId` is the entity ID in the relation schema.
*
* The `[pathname]` is determined by the `pathname` parameter.
*
* @remarks
* The `POST /:id/[pathname]` route accepts a JSON body with the following format:
*
* ```json
* { "[relationSchemaIds]": ["id1", "id2", "id3"] }
* ```
*
* The `[relationSchemaIds]` is the camel case of the relation schema's table name in
* singular form with `Ids` suffix. For example, if the relation schema's table name is
* `organization_roles`, the `[relationSchemaIds]` will be `organizationRoleIds`.
*
* @param relationSchema The schema of the relation to be added.
* @param relationQueries The queries for the relation.
* @param pathname The pathname of the relation. If not provided, it will be
* the camel case of the relation schema's table name.
* @see {@link RelationQueries} for the `relationQueries` configuration.
*/
addRelationRoutes<
RelationKey extends string,
RelationCreateSchema extends Partial<SchemaLike<RelationKey> & { id: string }>,
RelationSchema extends SchemaLike<RelationKey> & { id: string },
>(
relationSchema: GeneratedSchema<RelationKey, RelationCreateSchema, RelationSchema>,
relationQueries: RelationQueries<[typeof this.schema, typeof relationSchema]>,
pathname = tableToPathname(relationSchema.table)
) {
const columns = {
schemaId: camelCaseSchemaId(this.schema),
relationSchemaId: camelCaseSchemaId(relationSchema),
relationSchemaIds: camelCaseSchemaId(relationSchema) + 's',
};
this.get(
`/:id/${pathname}`,
koaGuard({
params: z.object({ id: z.string().min(1) }),
response: relationSchema.guard.array(),
status: [200, 404],
}),
async (ctx, next) => {
const { id } = ctx.guard.params;
// Ensure that the main entry exists
await this.actions.getById(id);
ctx.body = await relationQueries.getEntries(relationSchema, {
[columns.schemaId]: id,
});
return next();
}
);
this.post(
`/:id/${pathname}`,
koaGuard({
params: z.object({ id: z.string().min(1) }),
body: z.object({ [columns.relationSchemaIds]: z.string().min(1).array().nonempty() }),
response: relationSchema.guard.array(),
status: [200, 404, 422],
}),
async (ctx, next) => {
const {
params: { id },
body: { [columns.relationSchemaIds]: relationIds },
} = ctx.guard;
await relationQueries.insert(
...(relationIds?.map<[string, string]>((relationId) => [id, relationId]) ?? [])
);
ctx.body = await relationQueries.getEntries(relationSchema, { [columns.schemaId]: id });
return next();
}
);
this.delete(
`/:id/${pathname}/:relationId`,
koaGuard({
params: z.object({ id: z.string().min(1), relationId: z.string().min(1) }),
status: [204, 422],
}),
async (ctx, next) => {
const {
params: { id, relationId },
} = ctx.guard;
await relationQueries.delete({
[columns.schemaId]: id,
[columns.relationSchemaId]: relationId,
});
ctx.status = 204;
return next();
}
);
}
}

View file

@ -5,14 +5,14 @@ import { ApiFactory } from './factory.js';
class OrganizationRoleApi extends ApiFactory<
OrganizationRole,
{ name: string; description?: string; scopeIds?: string[] }
{ name: string; description?: string; organizationScopeIds?: string[] }
> {
constructor() {
super('organization-roles');
}
async addScopes(id: string, scopeIds: string[]): Promise<void> {
await authedAdminApi.post(`${this.path}/${id}/scopes`, { json: { scopeIds } });
async addScopes(id: string, organizationScopeIds: string[]): Promise<void> {
await authedAdminApi.post(`${this.path}/${id}/scopes`, { json: { organizationScopeIds } });
}
async getScopes(id: string): Promise<OrganizationScope[]> {

View file

@ -35,8 +35,8 @@ describe('organization role APIs', () => {
scopeApi.create({ name: 'test' + randomId() }),
scopeApi.create({ name: 'test' + randomId() }),
]);
const scopeIds = [scope1.id, scope2.id];
const role = await roleApi.create({ name, scopeIds });
const organizationScopeIds = [scope1.id, scope2.id];
const role = await roleApi.create({ name, organizationScopeIds });
expect(role).toStrictEqual(
expect.objectContaining({

View file

@ -3178,6 +3178,9 @@ importers:
'@withtyped/client':
specifier: ^0.7.22
version: 0.7.22(zod@3.22.3)
camelcase:
specifier: ^8.0.0
version: 8.0.0
chalk:
specifier: ^5.0.0
version: 5.1.2
@ -3265,9 +3268,6 @@ importers:
pg-protocol:
specifier: ^1.6.0
version: 1.6.0
pluralize:
specifier: ^8.0.0
version: 8.0.0
qrcode:
specifier: ^1.5.3
version: 1.5.3
@ -3344,9 +3344,6 @@ importers:
'@types/oidc-provider':
specifier: ^8.0.0
version: 8.0.0
'@types/pluralize':
specifier: ^0.0.31
version: 0.0.31
'@types/qrcode':
specifier: ^1.5.2
version: 1.5.2
@ -3367,7 +3364,7 @@ importers:
version: 8.44.0
jest:
specifier: ^29.5.0
version: 29.5.0(@types/node@18.11.18)(ts-node@10.9.1)
version: 29.5.0(@types/node@18.11.18)
jest-matcher-specific-error:
specifier: ^1.0.0
version: 1.0.0
@ -6960,6 +6957,48 @@ packages:
slash: 3.0.0
dev: true
/@jest/core@29.5.0:
resolution: {integrity: sha512-28UzQc7ulUrOQw1IsN/kv1QES3q2kkbl/wGslyhAclqZ/8cMdB5M68BffkIdSJgKBUt50d3hbwJ92XESlE7LiQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
peerDependencies:
node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0
peerDependenciesMeta:
node-notifier:
optional: true
dependencies:
'@jest/console': 29.5.0
'@jest/reporters': 29.5.0
'@jest/test-result': 29.5.0
'@jest/transform': 29.5.0
'@jest/types': 29.5.0
'@types/node': 18.11.18
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 3.8.0
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.5.0
jest-config: 29.5.0(@types/node@18.11.18)
jest-haste-map: 29.5.0
jest-message-util: 29.5.0
jest-regex-util: 29.4.3
jest-resolve: 29.5.0
jest-resolve-dependencies: 29.5.0
jest-runner: 29.5.0
jest-runtime: 29.5.0
jest-snapshot: 29.5.0
jest-util: 29.5.0
jest-validate: 29.5.0
jest-watcher: 29.5.0
micromatch: 4.0.5
pretty-format: 29.5.0
slash: 3.0.0
strip-ansi: 6.0.1
transitivePeerDependencies:
- supports-color
- ts-node
dev: true
/@jest/core@29.5.0(ts-node@10.9.1):
resolution: {integrity: sha512-28UzQc7ulUrOQw1IsN/kv1QES3q2kkbl/wGslyhAclqZ/8cMdB5M68BffkIdSJgKBUt50d3hbwJ92XESlE7LiQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -10952,7 +10991,6 @@ packages:
/camelcase@8.0.0:
resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==}
engines: {node: '>=16'}
dev: true
/caniuse-lite@1.0.30001486:
resolution: {integrity: sha512-uv7/gXuHi10Whlj0pp5q/tsK/32J2QSqVRKQhs2j8VsDCjgyruAh/eEXHF822VqO9yT6iZKw3nRwZRSPBE9OQg==}
@ -14450,6 +14488,34 @@ packages:
- supports-color
dev: true
/jest-cli@29.5.0(@types/node@18.11.18):
resolution: {integrity: sha512-L1KcP1l4HtfwdxXNFCL5bmUbLQiKrakMUriBEcc1Vfz6gx31ORKdreuWvmQVBit+1ss9NNR3yxjwfwzZNdQXJw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
hasBin: true
peerDependencies:
node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0
peerDependenciesMeta:
node-notifier:
optional: true
dependencies:
'@jest/core': 29.5.0
'@jest/test-result': 29.5.0
'@jest/types': 29.5.0
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.11
import-local: 3.1.0
jest-config: 29.5.0(@types/node@18.11.18)
jest-util: 29.5.0
jest-validate: 29.5.0
prompts: 2.4.2
yargs: 17.7.2
transitivePeerDependencies:
- '@types/node'
- supports-color
- ts-node
dev: true
/jest-cli@29.5.0(@types/node@18.11.18)(ts-node@10.9.1):
resolution: {integrity: sha512-L1KcP1l4HtfwdxXNFCL5bmUbLQiKrakMUriBEcc1Vfz6gx31ORKdreuWvmQVBit+1ss9NNR3yxjwfwzZNdQXJw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -14478,6 +14544,45 @@ packages:
- ts-node
dev: true
/jest-config@29.5.0(@types/node@18.11.18):
resolution: {integrity: sha512-kvDUKBnNJPNBmFFOhDbm59iu1Fii1Q6SxyhXfvylq3UTHbg6o7j/g8k2dZyXWLvfdKB1vAPxNZnMgtKJcmu3kA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
peerDependencies:
'@types/node': '*'
ts-node: '>=9.0.0'
peerDependenciesMeta:
'@types/node':
optional: true
ts-node:
optional: true
dependencies:
'@babel/core': 7.20.2
'@jest/test-sequencer': 29.5.0
'@jest/types': 29.5.0
'@types/node': 18.11.18
babel-jest: 29.5.0(@babel/core@7.20.2)
chalk: 4.1.2
ci-info: 3.8.0
deepmerge: 4.3.1
glob: 7.2.3
graceful-fs: 4.2.11
jest-circus: 29.5.0
jest-environment-node: 29.5.0
jest-get-type: 29.4.3
jest-regex-util: 29.4.3
jest-resolve: 29.5.0
jest-runner: 29.5.0
jest-util: 29.5.0
jest-validate: 29.5.0
micromatch: 4.0.5
parse-json: 5.2.0
pretty-format: 29.5.0
slash: 3.0.0
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
dev: true
/jest-config@29.5.0(@types/node@18.11.18)(ts-node@10.9.1):
resolution: {integrity: sha512-kvDUKBnNJPNBmFFOhDbm59iu1Fii1Q6SxyhXfvylq3UTHbg6o7j/g8k2dZyXWLvfdKB1vAPxNZnMgtKJcmu3kA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -14889,6 +14994,26 @@ packages:
supports-color: 8.1.1
dev: true
/jest@29.5.0(@types/node@18.11.18):
resolution: {integrity: sha512-juMg3he2uru1QoXX078zTa7pO85QyB9xajZc6bU+d9yEGwrKX6+vGmJQ3UdVZsvTEUARIdObzH68QItim6OSSQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
hasBin: true
peerDependencies:
node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0
peerDependenciesMeta:
node-notifier:
optional: true
dependencies:
'@jest/core': 29.5.0
'@jest/types': 29.5.0
import-local: 3.1.0
jest-cli: 29.5.0(@types/node@18.11.18)
transitivePeerDependencies:
- '@types/node'
- supports-color
- ts-node
dev: true
/jest@29.5.0(@types/node@18.11.18)(ts-node@10.9.1):
resolution: {integrity: sha512-juMg3he2uru1QoXX078zTa7pO85QyB9xajZc6bU+d9yEGwrKX6+vGmJQ3UdVZsvTEUARIdObzH68QItim6OSSQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -17317,6 +17442,7 @@ packages:
/pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
dev: true
/pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}