feat: Add PATCH method support to API components

This commit is contained in:
Simon Larsen
2024-08-14 16:42:07 +01:00
parent 7c2238eac7
commit 69dd24128a
10 changed files with 337 additions and 42 deletions

View File

@@ -167,7 +167,7 @@ export class AccessTokenService extends BaseService {
userId: ObjectID,
): Promise<UserGlobalAccessPermission> {
// query for all projects user belongs to.
const teamMembers: Array<TeamMember> = await TeamMemberService.findBy({
let teamMembers: Array<TeamMember> = await TeamMemberService.findBy({
query: {
userId: userId,
hasAcceptedInvitation: true,
@@ -182,6 +182,10 @@ export class AccessTokenService extends BaseService {
},
});
if (!teamMembers) {
teamMembers = [];
}
const projectIds: Array<ObjectID> = teamMembers.map(
(teamMember: TeamMember) => {
return teamMember.projectId!;

View File

@@ -0,0 +1,70 @@
import ComponentCode, { RunOptions, RunReturnType } from "../../ComponentCode";
import { ApiComponentUtils } from "./Utils";
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
import HTTPResponse from "Common/Types/API/HTTPResponse";
import URL from "Common/Types/API/URL";
import Dictionary from "Common/Types/Dictionary";
import APIException from "Common/Types/Exception/ApiException";
import BadDataException from "Common/Types/Exception/BadDataException";
import { JSONObject } from "Common/Types/JSON";
import ComponentMetadata, { Port } from "Common/Types/Workflow/Component";
import ComponentID from "Common/Types/Workflow/ComponentID";
import APIComponents from "Common/Types/Workflow/Components/API";
import API from "Common/Utils/API";
export default class ApiPut extends ComponentCode {
public constructor() {
super();
const Component: ComponentMetadata | undefined = APIComponents.find(
(i: ComponentMetadata) => {
return i.id === ComponentID.ApiPatch;
},
);
if (!Component) {
throw new BadDataException("Component not found.");
}
this.setMetadata(Component);
}
public override async run(
args: JSONObject,
options: RunOptions,
): Promise<RunReturnType> {
const result: { args: JSONObject; successPort: Port; errorPort: Port } =
ApiComponentUtils.sanitizeArgs(this.getMetadata(), args, options);
let apiResult: HTTPResponse<JSONObject> | HTTPErrorResponse | null = null;
try {
apiResult = await API.patch(
args["url"] as URL,
args["request-body"] as JSONObject,
args["request-headers"] as Dictionary<string>,
);
return Promise.resolve({
returnValues: ApiComponentUtils.getReturnValues(apiResult),
executePort: result.successPort,
});
} catch (err) {
if (err instanceof HTTPErrorResponse) {
return Promise.resolve({
returnValues: ApiComponentUtils.getReturnValues(err),
executePort: result.successPort,
});
}
if (apiResult) {
return Promise.resolve({
returnValues: ApiComponentUtils.getReturnValues(apiResult),
executePort: result.successPort,
});
}
throw options.onError(new APIException("Something wrong happened."));
}
}
}

View File

@@ -32,6 +32,7 @@ import BaseModel from "Common/Models/DatabaseModels/DatabaseBaseModel/DatabaseBa
import Dictionary from "Common/Types/Dictionary";
import Text from "Common/Types/Text";
import ComponentID from "Common/Types/Workflow/ComponentID";
import ApiPatch from "./API/Patch";
const Components: Dictionary<ComponentCode> = {
[ComponentID.Webhook]: new WebhookTrigger(),
@@ -48,6 +49,7 @@ const Components: Dictionary<ComponentCode> = {
[ComponentID.ApiGet]: new ApiGet(),
[ComponentID.ApiPost]: new ApiPost(),
[ComponentID.ApiDelete]: new ApiDelete(),
[ComponentID.ApiPatch]: new ApiPatch(),
[ComponentID.ApiPut]: new ApiPut(),
[ComponentID.SendEmail]: new Email(),
[ComponentID.IfElse]: new IfElse(),

View File

@@ -28,6 +28,7 @@ jest.setTimeout(60000); // Increase test timeout to 60 seconds becuase GitHub ru
describe("TeamMemberService", () => {
beforeEach(async () => {
jest.clearAllMocks();
jest.resetAllMocks();
await TestDatabaseMock.connectDbMock();
});
@@ -81,7 +82,6 @@ describe("TeamMemberService", () => {
describe("onBeforeCreate", () => {
it("should throw exception if the user limit for a project is reached", async () => {
const SEATS_LIMIT: number = 5;
const user: User = await UserServiceHelper.genrateAndSaveRandomUser(
null,
@@ -91,7 +91,9 @@ describe("TeamMemberService", () => {
);
const project: Project =
await ProjectServiceHelper.generateAndSaveRandomProject(null, {
await ProjectServiceHelper.generateAndSaveRandomProject({
seatLimit: 2,
}, {
isRoot: true,
userId: user.id!,
});
@@ -105,12 +107,6 @@ describe("TeamMemberService", () => {
},
);
ProjectService.findOneById = jest.fn().mockResolvedValue({
seatLimit: SEATS_LIMIT,
paymentProviderSubscriptionSeats: SEATS_LIMIT,
_id: project._id,
});
const tm: TeamMember = TeamMemberServiceHelper.generateRandomTeamMember(
{
projectId: new ObjectID(project._id!),
@@ -119,9 +115,31 @@ describe("TeamMemberService", () => {
},
);
await TeamMemberService.create({
data: tm,
props: { isRoot: true },
});
// create another team member
const user2: User = await UserServiceHelper.genrateAndSaveRandomUser(
null,
{
isRoot: true,
},
);
const tm2: TeamMember = TeamMemberServiceHelper.generateRandomTeamMember(
{
projectId: new ObjectID(project._id!),
userId: new ObjectID(user2._id!),
teamId: new ObjectID(team._id!),
},
);
await expect(
TeamMemberService.create({
data: tm,
data: tm2,
props: { isRoot: true },
}),
).rejects.toThrow(Errors.TeamMemberService.LIMIT_REACHED);
@@ -192,7 +210,7 @@ describe("TeamMemberService", () => {
const project: Project =
await ProjectServiceHelper.generateAndSaveRandomProject(null, {
isRoot: true,
userId: user.id!,
userId: user.id!,
});
const team: Team = await TeamServiceHelper.generateAndSaveRandomTeam(
@@ -208,13 +226,12 @@ describe("TeamMemberService", () => {
const tm: TeamMember = TeamMemberServiceHelper.generateRandomTeamMember(
{
projectId: new ObjectID(project._id!),
userId: new ObjectID(user._id!),
teamId: new ObjectID(team._id!),
miscDataProps: { email: nonExistingUserEmail },
},
);
const teamMember: TeamMember = await TeamMemberService.create({
data: tm,
miscDataProps: { email: nonExistingUserEmail },
props: { isRoot: true },
});
@@ -253,9 +270,7 @@ describe("TeamMemberService", () => {
const tm: TeamMember = TeamMemberServiceHelper.generateRandomTeamMember(
{
projectId: new ObjectID(project._id!),
userId: new ObjectID(user._id!),
teamId: new ObjectID(team._id!),
miscDataProps: { email: nonExistingUserEmail },
teamId: new ObjectID(team._id!)
},
);
@@ -265,6 +280,7 @@ describe("TeamMemberService", () => {
await TeamMemberService.create({
data: tm,
miscDataProps: { email: nonExistingUserEmail },
props: { isRoot: true },
});
@@ -606,12 +622,12 @@ describe("TeamMemberService", () => {
userId: user.id!,
});
const team: Team = TeamServiceHelper.generateRandomTeam({
let team: Team = TeamServiceHelper.generateRandomTeam({
projectId: new ObjectID(project._id!),
});
team.shouldHaveAtLeastOneMember = true;
await TeamService.create({
team = await TeamService.create({
data: team,
props: { isRoot: true },
});
@@ -669,11 +685,11 @@ describe("TeamMemberService", () => {
userId: user.id!,
});
const team: Team = TeamServiceHelper.generateRandomTeam({
let team: Team = TeamServiceHelper.generateRandomTeam({
projectId: new ObjectID(project._id!),
});
team.shouldHaveAtLeastOneMember = true;
await TeamService.create({
team = await TeamService.create({
data: team,
props: { isRoot: true },
});
@@ -720,7 +736,10 @@ describe("TeamMemberService", () => {
describe("refreshTokens", () => {
it("should refresh user global and tenant access permissions", async () => {
jest.restoreAllMocks();
// spy on refreshUserGlobalAccessPermission and refreshUserTenantAccessPermission
jest.spyOn(AccessTokenService, "refreshUserGlobalAccessPermission");
jest.spyOn(AccessTokenService, "refreshUserTenantAccessPermission");
const userId: ObjectID = new ObjectID(
Faker.generateRandomObjectID().toString(),
@@ -758,33 +777,85 @@ describe("TeamMemberService", () => {
userId: user.id!,
});
TeamMemberService.findBy = jest.fn().mockResolvedValue([
const teamA: Team = await TeamServiceHelper.generateAndSaveRandomTeam(
{
_id: Faker.generateRandomObjectID().toString(),
userId: Faker.generateRandomObjectID().toString(),
memberId: Faker.generateRandomObjectID().toString(),
projectId: new ObjectID(project.id!),
},
{
_id: Faker.generateRandomObjectID().toString(),
userId: "duplicated_id",
memberId: Faker.generateRandomObjectID().toString(),
isRoot: true,
},
);
const teamB: Team = await TeamServiceHelper.generateAndSaveRandomTeam(
{
projectId: new ObjectID(project.id!),
},
{
_id: Faker.generateRandomObjectID().toString(),
userId: "duplicated_id",
memberId: Faker.generateRandomObjectID().toString(),
isRoot: true,
},
{
_id: Faker.generateRandomObjectID().toString(),
memberId: Faker.generateRandomObjectID().toString(),
},
]);
);
// user A
const user1: User = await UserService.create({
data: UserServiceHelper.generateRandomUser(),
props: { isRoot: true },
});
// user B
const user2: User = await UserService.create({
data: UserServiceHelper.generateRandomUser(),
props: { isRoot: true },
});
// add these to team A
const teamMemberA1: TeamMember =
TeamMemberServiceHelper.generateRandomTeamMember({
projectId: new ObjectID(project._id!),
userId: new ObjectID(user1._id!),
teamId: new ObjectID(teamA._id!),
});
await TeamMemberService.create({
data: teamMemberA1,
props: { isRoot: true },
});
const teamMemberA2: TeamMember =
TeamMemberServiceHelper.generateRandomTeamMember({
projectId: new ObjectID(project._id!),
userId: new ObjectID(user2._id!),
teamId: new ObjectID(teamA._id!),
});
await TeamMemberService.create({
data: teamMemberA2,
props: { isRoot: true },
});
// add user 2 to team B
const teamMemberB2: TeamMember =
TeamMemberServiceHelper.generateRandomTeamMember({
projectId: new ObjectID(project._id!),
userId: new ObjectID(user2._id!),
teamId: new ObjectID(teamB._id!),
});
await TeamMemberService.create({
data: teamMemberB2,
props: { isRoot: true },
});
const count: number =
await TeamMemberService.getUniqueTeamMemberCountInProject(
new ObjectID(project._id!),
);
expect(count).toBe(2);
expect(count).toBe(3); // user, user1, user2
});
});
@@ -1047,6 +1118,13 @@ describe("TeamMemberService", () => {
const SUBSCRIPTION_ID: string = "subscriptionId";
it("should update subscription seats based on unique team members", async () => {
// spy on change quantity
jest.spyOn(BillingService, "changeQuantity");
// spy on update project
jest.spyOn(ProjectService, "updateOneById");
const user1: User = await UserService.create({
data: UserServiceHelper.generateRandomUser(),
props: { isRoot: true },
@@ -1130,6 +1208,12 @@ describe("TeamMemberService", () => {
process.env["SUBSCRIPTION_PLAN_1"] = undefined;
process.env["SUBSCRIPTION_PLAN_2"] = undefined;
// spy on change quantity
jest.spyOn(BillingService, "changeQuantity");
// spy on update project
jest.spyOn(ProjectService, "updateOneById");
const user: User = await UserServiceHelper.genrateAndSaveRandomUser(
null,
{

View File

@@ -6,7 +6,8 @@ import ProjectService from "../../../../Server/Services/ProjectService";
export interface ProjectData {
seatLimit?: number;
subscriptionId: string;
currentSeatCount?: number;
subscriptionId?: string;
}
export default class ProjectTestService {
@@ -37,6 +38,8 @@ export default class ProjectTestService {
project.seatLimit = data.seatLimit;
}
project.paymentProviderSubscriptionSeats = data?.currentSeatCount || 0;
project.smsOrCallCurrentBalanceInUSDCents = 0;
project.autoRechargeSmsOrCallByBalanceInUSD = 0;
project.autoRechargeSmsOrCallWhenCurrentBalanceFallsInUSD = 0;

View File

@@ -1,18 +1,19 @@
import { JSONObject } from "Common/Types/JSON";
import ObjectID from "Common/Types/ObjectID";
import TeamMember from "Common/Models/DatabaseModels/TeamMember";
export default class TeamMemberTestService {
public static generateRandomTeamMember(data: {
projectId: ObjectID;
userId: ObjectID;
userId?: ObjectID | undefined;
teamId: ObjectID;
miscDataProps?: JSONObject;
}): TeamMember {
const teamMember: TeamMember = new TeamMember();
// required fields
teamMember.userId = data.userId;
if(data.userId) {
teamMember.userId = data.userId;
}
teamMember.projectId = data.projectId;
teamMember.teamId = data.teamId;

View File

@@ -4,6 +4,7 @@ enum HTTPMethod {
DELETE = "DELETE",
PUT = "PUT",
HEAD = "HEAD",
PATCH = "PATCH",
}
export default HTTPMethod;

View File

@@ -13,6 +13,7 @@ enum ComponentID {
ApiPut = "api-put",
ApiPost = "api-post",
ApiDelete = "api-delete",
ApiPatch = "api-patch",
SendEmail = "send-email",
IfElse = "if-else",
}

View File

@@ -182,6 +182,92 @@ const components: Array<ComponentMetadata> = [
id: ComponentID.ApiPut,
title: "API Put (JSON)",
category: "API",
description: "Send a PUT Request and get JSON Response",
iconProp: IconProp.Globe,
componentType: ComponentType.Component,
arguments: [
{
id: "url",
name: "URL",
description: "URL to send request to.",
type: ComponentInputType.URL,
required: true,
placeholder: "https://api.yourcompany.com",
},
{
id: "request-body",
name: "Request Body",
description: "Request Body in JSON",
type: ComponentInputType.JSON,
required: false,
placeholder: 'Example: {"key1": "value1", "key2": "value2", ....}',
},
{
id: "request-headers",
name: "Request Headers",
description: "Request headers to send.",
type: ComponentInputType.StringDictionary,
required: false,
isAdvanced: true,
placeholder:
'Example: {"header1": "value1", "header2": "value2", ....}',
},
],
returnValues: [
{
id: "error",
name: "Error",
description: "Error, if there is any.",
type: ComponentInputType.Text,
required: false,
},
{
id: "response-status",
name: "Response Status",
description: "Response Status (200, for example)",
type: ComponentInputType.Number,
required: false,
},
{
id: "response-headers",
name: "Response Headers",
description: "Response Headers for this request",
type: ComponentInputType.StringDictionary,
required: false,
},
{
id: "response-body",
name: "Response Body",
description: "Response Body",
type: ComponentInputType.JSON,
required: false,
},
],
inPorts: [
{
title: "In",
description:
"Please connect components to this port for this component to work.",
id: "in",
},
],
outPorts: [
{
title: "Success",
description: "This is executed when the message is successfully posted",
id: "success",
},
{
title: "Error",
description: "This is executed when there is an error",
id: "error",
},
],
},
{
id: ComponentID.ApiPatch,
title: "API Patch (JSON)",
category: "API",
description: "Send a PATCH Request and get JSON Response",
iconProp: IconProp.Globe,
componentType: ComponentType.Component,

View File

@@ -123,6 +123,23 @@ export default class API {
);
}
public async patch<
T extends JSONObject | JSONArray | BaseModel | Array<BaseModel>,
>(
path: Route,
data?: JSONObject | JSONArray,
headers?: Headers,
options?: RequestOptions,
): Promise<HTTPResponse<T> | HTTPErrorResponse> {
return await API.patch<T>(
new URL(this.protocol, this.hostname, this.baseRoute.addRoute(path)),
data,
headers,
options,
);
}
public async post<
T extends JSONObject | JSONArray | BaseModel | Array<BaseModel>,
>(
@@ -270,6 +287,32 @@ export default class API {
);
}
public static async patch<
T extends
| JSONObject
| JSONArray
| BaseModel
| Array<BaseModel>
| AnalyticsBaseModel
| Array<AnalyticsBaseModel>,
>(
url: URL,
data?: JSONObject | JSONArray,
headers?: Headers,
options?: RequestOptions,
): Promise<HTTPResponse<T> | HTTPErrorResponse> {
return await this.fetch(
HTTPMethod.PATCH,
url,
data,
headers,
undefined,
options,
);
}
public static async post<
T extends
| JSONObject