diff --git a/packages/core/package.json b/packages/core/package.json index c7bdb3388..05c37c1de 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/queries/organizations.ts b/packages/core/src/queries/organizations.ts index 6bb21ed41..1342e1132 100644 --- a/packages/core/src/queries/organizations.ts +++ b/packages/core/src/queries/organizations.ts @@ -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) { diff --git a/packages/core/src/routes/organization-roles.ts b/packages/core/src/routes/organization-roles.ts index 3b5d42782..8a33d0789 100644 --- a/packages/core/src/routes/organization-roles.ts +++ b/packages/core/src/routes/organization-roles.ts @@ -51,7 +51,9 @@ export default function organizationRoleRoutes( 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 & { scopeIds: string[] }; + type CreateOrganizationRolePayload = Omit & { + organizationScopeIds: string[]; + }; const createGuard: z.ZodType = OrganizationRoles.createGuard @@ -59,7 +61,7 @@ export default function organizationRoleRoutes( 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( 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( } ); - // 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()); } diff --git a/packages/core/src/utils/SchemaRouter.ts b/packages/core/src/utils/SchemaRouter.ts index 92d59ee8a..8ecb21f36 100644 --- a/packages/core/src/utils/SchemaRouter.ts +++ b/packages/core/src/utils/SchemaRouter.ts @@ -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 = (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, config: DeepPartial = {} ) { - super({ prefix: '/' + schema.table.replaceAll('_', '-') }); + super({ prefix: '/' + tableToPathname(schema.table) }); this.config = deepmerge>( { @@ -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 & { id: string }>, + RelationSchema extends SchemaLike & { id: string }, + >( + relationSchema: GeneratedSchema, + 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(); + } + ); + } } diff --git a/packages/integration-tests/src/api/organization-role.ts b/packages/integration-tests/src/api/organization-role.ts index e446a8d0f..7882e7f87 100644 --- a/packages/integration-tests/src/api/organization-role.ts +++ b/packages/integration-tests/src/api/organization-role.ts @@ -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 { - await authedAdminApi.post(`${this.path}/${id}/scopes`, { json: { scopeIds } }); + async addScopes(id: string, organizationScopeIds: string[]): Promise { + await authedAdminApi.post(`${this.path}/${id}/scopes`, { json: { organizationScopeIds } }); } async getScopes(id: string): Promise { diff --git a/packages/integration-tests/src/tests/api/organization-role.test.ts b/packages/integration-tests/src/tests/api/organization-role.test.ts index d463a817f..a071c8931 100644 --- a/packages/integration-tests/src/tests/api/organization-role.test.ts +++ b/packages/integration-tests/src/tests/api/organization-role.test.ts @@ -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({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 914b7a3d4..5ffed86af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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==}