Skip to content

Commit

Permalink
Merge pull request #6064 from logto-io/gao-init-org-app-apis
Browse files Browse the repository at this point in the history
feat(core): init organization app apis
  • Loading branch information
gao-sun authored Jun 20, 2024
2 parents e83e94f + 34a6411 commit 5362772
Show file tree
Hide file tree
Showing 19 changed files with 405 additions and 40 deletions.
9 changes: 9 additions & 0 deletions packages/core/src/queries/organization/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
Resources,
Users,
OrganizationJitRoles,
OrganizationApplicationRelations,
Applications,
} from '@logto/schemas';
import { sql, type CommonQueryMethods } from '@silverhand/slonik';

Expand Down Expand Up @@ -283,6 +285,13 @@ export default class OrganizationQueries extends SchemaQueries<
users: new UserRelationQueries(this.pool),
/** Queries for organization - organization role - user relations. */
rolesUsers: new RoleUserRelationQueries(this.pool),
/** Queries for organization - application relations. */
apps: new TwoRelationsQueries(
this.pool,
OrganizationApplicationRelations.table,
Organizations,
Applications
),
invitationsRoles: new TwoRelationsQueries(
this.pool,
OrganizationInvitationRoleRelations.table,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
{
"tags": [
{
"name": "Organization applications",
"description": "Manage organization - application relationships. An application can be associated with one or more organizations in order to grant organization access to the application.\n\nCurrently, only machine-to-machine applications can be associated with organizations."
}
],
"paths": {
"/api/organizations/{id}/applications": {
"get": {
"summary": "Get organization applications",
"description": "Get applications associated with the organization.",
"responses": {
"200": {
"description": "A list of applications."
}
}
},
"post": {
"summary": "Add organization application",
"description": "Add an application to the organization.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"applicationIds": {
"description": "The application IDs to add."
}
}
}
}
}
},
"responses": {
"201": {
"description": "The application was added successfully."
},
"422": {
"description": "The application could not be added. Some of the applications may not exist."
}
}
},
"put": {
"summary": "Replace organization applications",
"description": "Replace all applications associated with the organization with the given data.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"applicationIds": {
"description": "An array of application IDs to replace existing applications."
}
}
}
}
}
},
"responses": {
"204": {
"description": "The applications were replaced successfully."
},
"422": {
"description": "The applications could not be replaced. Some of the applications may not exist."
}
}
}
},
"/api/organizations/{id}/applications/{applicationId}": {
"delete": {
"summary": "Remove organization application",
"description": "Remove an application from the organization.",
"responses": {
"204": {
"description": "The application was removed from the organization successfully."
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@
"responses": {
"204": {
"description": "The organization roles were replaced successfully."
},
"422": {
"description": "The organization roles could not be replaced. Some of the organization roles may not exist."
}
}
}
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/routes/organization/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { yes } from '@silverhand/essentials';
import { z } from 'zod';

import { EnvSet } from '#src/env-set/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import koaQuotaGuard from '#src/middleware/koa-quota-guard.js';
Expand Down Expand Up @@ -139,6 +140,13 @@ export default function organizationRoutes<T extends ManagementApiRouter>(

userRoleRelationRoutes(router, organizations);

// MARK: Organization - application relation routes
if (EnvSet.values.isDevFeaturesEnabled) {
router.addRelationRoutes(organizations.relations.apps, undefined, {
hookEvent: 'Organization.Membership.Updated',
});
}

// MARK: Just-in-time provisioning
emailDomainRoutes(router, organizations);
router.addRelationRoutes(organizations.jit.roles, 'jit/roles', { isPaginationOptional: true });
Expand Down
9 changes: 6 additions & 3 deletions packages/core/src/routes/swagger/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ const isManagementApiRouter = ({ stack }: Router) =>

// Add more components here to cover more ID parameters in paths. For example, if there is a
// path `/foo/:barBazId`, then add `bar-baz` to the array.
const identifiableEntityNames = [
const identifiableEntityNames = Object.freeze([
'key',
'connector-factory',
'factory',
Expand All @@ -131,7 +131,10 @@ const identifiableEntityNames = [
'organization-role',
'organization-scope',
'organization-invitation',
];
]);

/** Additional tags that cannot be inferred from the path. */
const additionalTags = Object.freeze(['Organization applications']);

/**
* Attach the `/swagger.json` route which returns the generated OpenAPI document for the
Expand Down Expand Up @@ -229,7 +232,7 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
{}
),
},
tags: [...tags].map((tag) => ({ name: tag })),
tags: [...tags, ...additionalTags].map((tag) => ({ name: tag })),
};

const data = supplementDocuments.reduce<OpenAPIV3.Document>(
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/routes/swagger/utils/general.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,9 @@ export const validateSupplement = (

for (const { name } of supplementTags) {
if (!originalTags.has(name)) {
throw new TypeError(`Supplement document contains extra tag \`${name}\`.`);
throw new TypeError(
`Supplement document contains extra tag \`${name}\`. If you want to add a new tag, please add it to the \`additionalTags\` array in the main swagger route file.`
);
}
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/utils/SchemaRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ export default class SchemaRouter<
*
* - `GET /:id/[pathname]`: Get the entities of the relation with pagination.
* - `POST /:id/[pathname]`: Add entities to the relation.
* - `PUT /:id/[pathname]`: Replace the entities in the relation.
* - `DELETE /:id/[pathname]/:relationSchemaId`: Remove an entity from the relation set.
* The `:relationSchemaId` is the entity ID in the relation schema.
*
Expand Down
71 changes: 71 additions & 0 deletions packages/integration-tests/src/api/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,74 @@ export class ApiFactory<
await authedAdminApi.delete(this.path + '/' + id);
}
}

type RelationApiFactoryConfig = {
/** The base path of the API. */
basePath: string;
/**
* The path of the relation. It will to be appended to the base path and the ID of the parent.
*
* @example
* If the base path is `organizations` and the relation path is `applications`, the final paths
* will be `organizations/:id/applications` and `organizations/:id/applications/:applicationId`.
*/
relationPath: string;
/**
* The key name of the relation IDs. It will be used in the request body.
*
* @example
* If the key name is `applicationIds`, the request body will be
* `{ applicationIds: ['id1', 'id2'] }`.
*/
relationKey: string;
};

export class RelationApiFactory<RelationSchema extends Record<string, unknown>> {
constructor(public readonly config: RelationApiFactoryConfig) {}

get basePath(): string {
return this.config.basePath;
}

get relationPath(): string {
return this.config.relationPath;
}

get relationKey(): string {
return this.config.relationKey;
}

async getList(id: string, page?: number, pageSize?: number): Promise<RelationSchema[]> {
const searchParams = new URLSearchParams();

if (page) {
searchParams.append('page', String(page));
}

if (pageSize) {
searchParams.append('page_size', String(pageSize));
}

return transform(
await authedAdminApi
.get(`${this.basePath}/${id}/${this.relationPath}`, { searchParams })
.json<RelationSchema[]>()
);
}

async add(id: string, relationIds: string[]): Promise<void> {
await authedAdminApi.post(`${this.basePath}/${id}/${this.relationPath}`, {
json: { [this.relationKey]: relationIds },
});
}

async delete(id: string, relationId: string): Promise<void> {
await authedAdminApi.delete(`${this.basePath}/${id}/${this.relationPath}/${relationId}`);
}

async replace(id: string, relationIds: string[]): Promise<void> {
await authedAdminApi.put(`${this.basePath}/${id}/${this.relationPath}`, {
json: { [this.relationKey]: relationIds },
});
}
}
23 changes: 7 additions & 16 deletions packages/integration-tests/src/api/organization-jit.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { type OrganizationRole, type OrganizationJitEmailDomain } from '@logto/schemas';

import { authedAdminApi } from './api.js';
import { RelationApiFactory } from './factory.js';

export class OrganizationJitApi {
roles = new RelationApiFactory<OrganizationRole>({
basePath: 'organizations',
relationPath: 'jit/roles',
relationKey: 'organizationRoleIds',
});

constructor(public path: string) {}

async getEmailDomains(
Expand Down Expand Up @@ -36,20 +43,4 @@ export class OrganizationJitApi {
async replaceEmailDomains(id: string, emailDomains: string[]): Promise<void> {
await authedAdminApi.put(`${this.path}/${id}/jit/email-domains`, { json: { emailDomains } });
}

async getRoles(id: string): Promise<OrganizationRole[]> {
return authedAdminApi.get(`${this.path}/${id}/jit/roles`).json<OrganizationRole[]>();
}

async addRole(id: string, organizationRoleIds: string[]): Promise<void> {
await authedAdminApi.post(`${this.path}/${id}/jit/roles`, { json: { organizationRoleIds } });
}

async deleteRole(id: string, organizationRoleId: string): Promise<void> {
await authedAdminApi.delete(`${this.path}/${id}/jit/roles/${organizationRoleId}`);
}

async replaceRoles(id: string, organizationRoleIds: string[]): Promise<void> {
await authedAdminApi.put(`${this.path}/${id}/jit/roles`, { json: { organizationRoleIds } });
}
}
8 changes: 7 additions & 1 deletion packages/integration-tests/src/api/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import {
type OrganizationWithFeatured,
type OrganizationScope,
type CreateOrganization,
type Application,
} from '@logto/schemas';

import { authedAdminApi } from './api.js';
import { ApiFactory } from './factory.js';
import { ApiFactory, RelationApiFactory } from './factory.js';
import { OrganizationJitApi } from './organization-jit.js';

type Query = {
Expand All @@ -20,6 +21,11 @@ type Query = {

export class OrganizationApi extends ApiFactory<Organization, Omit<CreateOrganization, 'id'>> {
jit = new OrganizationJitApi(this.path);
applications = new RelationApiFactory<Application>({
basePath: 'organizations',
relationPath: 'applications',
relationKey: 'applicationIds',
});

constructor() {
super('organizations');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ describe('organization just-in-time provisioning', () => {
)
);
await Promise.all([
organizationApi.jit.addRole(organizations[0].id, [roles[0].id, roles[1].id]),
organizationApi.jit.addRole(organizations[1].id, [roles[0].id]),
organizationApi.jit.roles.add(organizations[0].id, [roles[0].id, roles[1].id]),
organizationApi.jit.roles.add(organizations[1].id, [roles[0].id]),
]);

const email = randomString() + '@' + emailDomain;
Expand Down
Loading

0 comments on commit 5362772

Please sign in to comment.